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

View File

@@ -11,6 +11,7 @@ import (
// Config is the top-level configuration for Metacrypt.
type Config struct {
Server ServerConfig `toml:"server"`
Web WebConfig `toml:"web"`
Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"`
Seal SealConfig `toml:"seal"`
@@ -26,6 +27,20 @@ type ServerConfig struct {
ExternalURL string `toml:"external_url"` // public base URL for ACME directory, e.g. "https://metacrypt.example.com"
}
// WebConfig holds settings for the standalone web UI server (metacrypt-web).
type WebConfig struct {
// ListenAddr is the address the web server listens on (default: 127.0.0.1:8080).
ListenAddr string `toml:"listen_addr"`
// VaultGRPC is the gRPC address of the vault server (e.g. "127.0.0.1:9443").
VaultGRPC string `toml:"vault_grpc"`
// VaultCACert is the path to the CA certificate used to verify the vault's TLS cert.
VaultCACert string `toml:"vault_ca_cert"`
// TLSCert and TLSKey are optional. If empty, the web server uses plain HTTP
// (suitable for deployment behind a TLS-terminating reverse proxy).
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
}
// DatabaseConfig holds SQLite database settings.
type DatabaseConfig struct {
Path string `toml:"path"`
@@ -98,5 +113,10 @@ func (c *Config) Validate() error {
c.Log.Level = "info"
}
// Apply defaults for web server.
if c.Web.ListenAddr == "" {
c.Web.ListenAddr = "127.0.0.1:8080"
}
return nil
}

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
}

View File

@@ -1,24 +1,16 @@
package server
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
@@ -27,25 +19,7 @@ import (
)
func (s *Server) registerRoutes(r chi.Router) {
// Static files.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
// Web UI routes.
r.Get("/", s.handleWebRoot)
r.HandleFunc("/init", s.handleWebInit)
r.HandleFunc("/unseal", s.handleWebUnseal)
r.HandleFunc("/login", s.handleWebLogin)
r.Get("/dashboard", s.requireAuthWeb(s.handleWebDashboard))
r.Post("/dashboard/mount-ca", s.requireAuthWeb(s.handleWebDashboardMountCA))
r.Route("/pki", func(r chi.Router) {
r.Get("/", s.requireAuthWeb(s.handleWebPKI))
r.Post("/import-root", s.requireAuthWeb(s.handleWebImportRoot))
r.Post("/create-issuer", s.requireAuthWeb(s.handleWebCreateIssuer))
r.Get("/{issuer}", s.requireAuthWeb(s.handleWebPKIIssuer))
})
// API routes.
// REST API routes — web UI served by metacrypt-web.
r.Get("/v1/status", s.handleStatus)
r.Post("/v1/init", s.handleInit)
r.Post("/v1/unseal", s.handleUnseal)
@@ -67,7 +41,8 @@ func (s *Server) registerRoutes(r chi.Router) {
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
s.registerACMERoutes(r)}
s.registerACMERoutes(r)
}
// --- API Handlers ---
@@ -478,474 +453,6 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
return caEng, nil
}
// findCAMount returns the name of the first CA engine mount.
func (s *Server) findCAMount() (string, error) {
for _, m := range s.engines.ListMounts() {
if m.Type == engine.EngineTypeCA {
return m.Name, nil
}
}
return "", errors.New("no CA engine mounted")
}
// --- Web Handlers ---
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
state := s.seal.State()
switch state {
case seal.StateUninitialized:
http.Redirect(w, r, "/init", http.StatusFound)
case seal.StateSealed:
http.Redirect(w, r, "/unseal", http.StatusFound)
case seal.StateInitializing:
http.Redirect(w, r, "/init", http.StatusFound)
case seal.StateUnsealed:
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
}
func (s *Server) handleWebInit(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if s.seal.State() != seal.StateUninitialized {
http.Redirect(w, r, "/", http.StatusFound)
return
}
s.renderTemplate(w, "init.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if password == "" {
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
return
}
params := crypto.Argon2Params{
Time: s.cfg.Seal.Argon2Time,
Memory: s.cfg.Seal.Argon2Memory,
Threads: s.cfg.Seal.Argon2Threads,
}
if err := s.seal.Initialize(r.Context(), []byte(password), params); err != nil {
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": err.Error()})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state := s.seal.State()
if state == seal.StateUninitialized {
http.Redirect(w, r, "/init", http.StatusFound)
return
}
if state == seal.StateUnsealed {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
s.renderTemplate(w, "unseal.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if err := s.seal.Unseal([]byte(password)); err != nil {
msg := "Invalid password"
if err == seal.ErrRateLimited {
msg = "Too many attempts. Please wait 60 seconds."
}
s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
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)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebLogin(w http.ResponseWriter, r *http.Request) {
if s.seal.State() != seal.StateUnsealed {
http.Redirect(w, r, "/", http.StatusFound)
return
}
switch r.Method {
case http.MethodGet:
s.renderTemplate(w, "login.html", nil)
case http.MethodPost:
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
totpCode := r.FormValue("totp_code")
token, _, err := s.auth.Login(username, password, totpCode)
if err != nil {
s.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "metacrypt_token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
mounts := s.engines.ListMounts()
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": s.seal.State().String(),
"Version": s.version,
})
}
func (s *Server) handleWebDashboardMountCA(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
mountName := r.FormValue("name")
if mountName == "" {
s.renderDashboardWithError(w, r, info, "Mount name is required")
return
}
config := map[string]interface{}{}
if org := r.FormValue("organization"); org != "" {
config["organization"] = org
}
// Optional root CA import.
var certPEM, keyPEM string
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
if certPEM != "" && keyPEM != "" {
config["root_cert_pem"] = certPEM
config["root_key_pem"] = keyPEM
}
if err := s.engines.Mount(r.Context(), mountName, engine.EngineTypeCA, config); err != nil {
s.renderDashboardWithError(w, r, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) renderDashboardWithError(w http.ResponseWriter, _ *http.Request, info *auth.TokenInfo, errMsg string) {
mounts := s.engines.ListMounts()
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": s.seal.State().String(),
"MountError": errMsg,
})
}
func (s *Server) handleWebPKI(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
mountName, err := s.findCAMount()
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
// Get root cert info.
rootPEM, err := caEng.GetRootCertPEM()
if err == nil && rootPEM != nil {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
// Get issuers.
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-issuers",
CallerInfo: callerInfo,
})
if err == nil {
data["Issuers"] = resp.Data["issuers"]
}
s.renderTemplate(w, "pki.html", data)
}
func (s *Server) handleWebImportRoot(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
certPEM := r.FormValue("cert_pem")
keyPEM := r.FormValue("key_pem")
// Also support file uploads.
if certPEM == "" {
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
}
if keyPEM == "" {
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
}
if certPEM == "" || keyPEM == "" {
s.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required")
return
}
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
_, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "import-root",
CallerInfo: callerInfo,
Data: map[string]interface{}{
"cert_pem": certPEM,
"key_pem": keyPEM,
},
})
if err != nil {
s.renderPKIWithError(w, r, mountName, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) handleWebCreateIssuer(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.ParseForm()
name := r.FormValue("name")
if name == "" {
s.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
return
}
data := map[string]interface{}{
"name": name,
}
if v := r.FormValue("expiry"); v != "" {
data["expiry"] = v
}
if v := r.FormValue("max_ttl"); v != "" {
data["max_ttl"] = v
}
if v := r.FormValue("key_algorithm"); v != "" {
data["key_algorithm"] = v
}
if v := r.FormValue("key_size"); v != "" {
// Parse as float64 to match JSON number convention used by the engine.
var size float64
if _, err := fmt.Sscanf(v, "%f", &size); err == nil {
data["key_size"] = size
}
}
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
_, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "create-issuer",
CallerInfo: callerInfo,
Data: data,
})
if err != nil {
s.renderPKIWithError(w, r, mountName, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) handleWebPKIIssuer(w http.ResponseWriter, r *http.Request) {
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
issuerName := chi.URLParam(r, "issuer")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
certPEM, err := caEng.GetIssuerCertPEM(issuerName)
if err != nil {
http.Error(w, "issuer not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.pem", issuerName))
w.Write(certPEM)
}
func (s *Server) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *auth.TokenInfo, errMsg string) {
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
// Try to load existing root info.
mount, merr := s.engines.GetMount(mountName)
if merr == nil && mount.Type == engine.EngineTypeCA {
if caEng, ok := mount.Engine.(*ca.CAEngine); ok {
rootPEM, err := caEng.GetRootCertPEM()
if err == nil && rootPEM != nil {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
}
}
s.renderTemplate(w, "pki.html", data)
}
func parsePEMCert(pemData []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
// requireAuthWeb redirects to login for web pages instead of returning 401.
func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.seal.State() != seal.StateUnsealed {
http.Redirect(w, r, "/", http.StatusFound)
return
}
token := extractToken(r)
if token == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
info, err := s.auth.ValidateToken(token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, tokenInfoKey, info)
next(w, r.WithContext(ctx))
}
}
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
tmpl, err := template.ParseFiles(
filepath.Join("web", "templates", "layout.html"),
filepath.Join("web", "templates", name),
)
if err != nil {
s.logger.Error("parse template", "name", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
s.logger.Error("execute template", "name", name, "error", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -151,18 +151,15 @@ func TestStatusMethodNotAllowed(t *testing.T) {
}
}
func TestRootRedirect(t *testing.T) {
func TestRootNotFound(t *testing.T) {
_, _, mux := setupTestServer(t)
// The vault server no longer serves a web UI at /; that is handled by metacrypt-web.
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Errorf("root redirect: got %d, want %d", w.Code, http.StatusFound)
}
loc := w.Header().Get("Location")
if loc != "/init" {
t.Errorf("redirect location: got %q, want /init", loc)
if w.Code != http.StatusNotFound {
t.Errorf("root: got %d, want %d", w.Code, http.StatusNotFound)
}
}

View File

@@ -0,0 +1,199 @@
package webserver
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
)
// VaultClient wraps the gRPC stubs for communicating with the vault.
type VaultClient struct {
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
}
// NewVaultClient dials the vault gRPC server and returns a client.
func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
pemData, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("webserver: read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("webserver: parse CA cert")
}
tlsCfg.RootCAs = pool
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("webserver: dial vault: %w", err)
}
return &VaultClient{
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
}, nil
}
// Close closes the underlying connection.
func (c *VaultClient) Close() error {
return c.conn.Close()
}
// withToken returns a context with the Bearer token in outgoing metadata.
func withToken(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
// Status returns the current vault state string (e.g. "unsealed").
func (c *VaultClient) Status(ctx context.Context) (string, error) {
resp, err := c.system.Status(ctx, &pb.StatusRequest{})
if err != nil {
return "", err
}
return resp.State, nil
}
// Init initializes the vault with the given password.
func (c *VaultClient) Init(ctx context.Context, password string) error {
_, err := c.system.Init(ctx, &pb.InitRequest{Password: password})
return err
}
// Unseal unseals the vault with the given password.
func (c *VaultClient) Unseal(ctx context.Context, password string) error {
_, err := c.system.Unseal(ctx, &pb.UnsealRequest{Password: password})
return err
}
// TokenInfo holds validated token details returned by the vault.
type TokenInfo struct {
Username string
Roles []string
IsAdmin bool
}
// Login authenticates against the vault and returns the session token.
func (c *VaultClient) Login(ctx context.Context, username, password, totpCode string) (string, error) {
resp, err := c.auth.Login(ctx, &pb.LoginRequest{
Username: username,
Password: password,
TotpCode: totpCode,
})
if err != nil {
return "", err
}
return resp.Token, nil
}
// ValidateToken validates a token against the vault and returns the token info.
func (c *VaultClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) {
resp, err := c.auth.TokenInfo(withToken(ctx, token), &pb.TokenInfoRequest{})
if err != nil {
return nil, err
}
return &TokenInfo{
Username: resp.Username,
Roles: resp.Roles,
IsAdmin: resp.IsAdmin,
}, nil
}
// MountInfo holds metadata about an engine mount.
type MountInfo struct {
Name string
Type string
MountPath string
}
// ListMounts returns all engine mounts from the vault.
func (c *VaultClient) ListMounts(ctx context.Context, token string) ([]MountInfo, error) {
resp, err := c.engine.ListMounts(withToken(ctx, token), &pb.ListMountsRequest{})
if err != nil {
return nil, err
}
mounts := make([]MountInfo, 0, len(resp.Mounts))
for _, m := range resp.Mounts {
mounts = append(mounts, MountInfo{
Name: m.Name,
Type: m.Type,
MountPath: m.MountPath,
})
}
return mounts, nil
}
// Mount creates a new engine mount on the vault.
func (c *VaultClient) Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error {
req := &pb.MountRequest{
Name: name,
Type: engineType,
}
if len(config) > 0 {
s, err := structFromMap(config)
if err != nil {
return fmt.Errorf("webserver: encode mount config: %w", err)
}
req.Config = s
}
_, err := c.engine.Mount(withToken(ctx, token), req)
return err
}
// EngineRequest sends a generic engine operation to the vault.
func (c *VaultClient) EngineRequest(ctx context.Context, token, mount, operation string, data map[string]interface{}) (map[string]interface{}, error) {
req := &pb.EngineRequest{
Mount: mount,
Operation: operation,
}
if len(data) > 0 {
s, err := structFromMap(data)
if err != nil {
return nil, fmt.Errorf("webserver: encode engine request: %w", err)
}
req.Data = s
}
resp, err := c.engine.Request(withToken(ctx, token), req)
if err != nil {
return nil, err
}
if resp.Data == nil {
return nil, nil
}
return resp.Data.AsMap(), nil
}
// GetRootCert returns the root CA certificate PEM for the given mount.
func (c *VaultClient) GetRootCert(ctx context.Context, mount string) ([]byte, error) {
resp, err := c.pki.GetRootCert(ctx, &pb.GetRootCertRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}
// GetIssuerCert returns a named issuer certificate PEM for the given mount.
func (c *VaultClient) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) {
resp, err := c.pki.GetIssuerCert(ctx, &pb.GetIssuerCertRequest{Mount: mount, Issuer: issuer})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}

View File

@@ -0,0 +1,430 @@
package webserver
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS))))
r.Get("/", ws.handleRoot)
r.HandleFunc("/init", ws.handleInit)
r.HandleFunc("/unseal", ws.handleUnseal)
r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Route("/pki", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
// requireAuth validates the token cookie against the vault and injects TokenInfo.
func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state, err := ws.vault.Status(r.Context())
if err != nil || state != "unsealed" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
token := extractCookie(r)
if token == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
info, err := ws.vault.ValidateToken(r.Context(), token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
r = r.WithContext(withTokenInfo(r.Context(), info))
next(w, r)
}
}
func (ws *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) {
state, err := ws.vault.Status(r.Context())
if err != nil {
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Cannot reach vault"})
return
}
switch state {
case "uninitialized", "initializing":
http.Redirect(w, r, "/init", http.StatusFound)
case "sealed":
http.Redirect(w, r, "/unseal", http.StatusFound)
case "unsealed":
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Redirect(w, r, "/unseal", http.StatusFound)
}
}
func (ws *WebServer) handleInit(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state, _ := ws.vault.Status(r.Context())
if state != "uninitialized" && state != "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
ws.renderTemplate(w, "init.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if password == "" {
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
return
}
if err := ws.vault.Init(r.Context(), password); err != nil {
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": grpcMessage(err)})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleUnseal(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state, _ := ws.vault.Status(r.Context())
if state == "uninitialized" {
http.Redirect(w, r, "/init", http.StatusFound)
return
}
if state == "unsealed" {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
ws.renderTemplate(w, "unseal.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if err := ws.vault.Unseal(r.Context(), password); err != nil {
msg := "Invalid password"
if st, ok := status.FromError(err); ok && st.Code() == codes.ResourceExhausted {
msg = "Too many attempts. Please wait 60 seconds."
}
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
state, _ := ws.vault.Status(r.Context())
if state != "unsealed" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
switch r.Method {
case http.MethodGet:
ws.renderTemplate(w, "login.html", nil)
case http.MethodPost:
r.ParseForm()
token, err := ws.vault.Login(r.Context(),
r.FormValue("username"),
r.FormValue("password"),
r.FormValue("totp_code"),
)
if err != nil {
ws.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "metacrypt_token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
})
}
func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
mountName := r.FormValue("name")
if mountName == "" {
ws.renderDashboardWithError(w, r, info, "Mount name is required")
return
}
cfg := map[string]interface{}{}
if org := r.FormValue("organization"); org != "" {
cfg["organization"] = org
}
var certPEM, keyPEM string
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
if certPEM != "" && keyPEM != "" {
cfg["root_cert_pem"] = certPEM
cfg["root_key_pem"] = keyPEM
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, "ca", cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
"MountError": errMsg,
})
}
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
if resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = resp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) handleImportRoot(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
r.ParseForm()
}
certPEM := r.FormValue("cert_pem")
keyPEM := r.FormValue("key_pem")
if certPEM == "" {
if f, _, err := r.FormFile("cert_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
certPEM = string(data)
}
}
if keyPEM == "" {
if f, _, err := r.FormFile("key_file"); err == nil {
defer f.Close()
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
keyPEM = string(data)
}
}
if certPEM == "" || keyPEM == "" {
ws.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required")
return
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "import-root", map[string]interface{}{
"cert_pem": certPEM,
"key_pem": keyPEM,
})
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handleCreateIssuer(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.ParseForm()
name := r.FormValue("name")
if name == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
return
}
reqData := map[string]interface{}{"name": name}
if v := r.FormValue("expiry"); v != "" {
reqData["expiry"] = v
}
if v := r.FormValue("max_ttl"); v != "" {
reqData["max_ttl"] = v
}
if v := r.FormValue("key_algorithm"); v != "" {
reqData["key_algorithm"] = v
}
if v := r.FormValue("key_size"); v != "" {
var size float64
if _, err := fmt.Sscanf(v, "%f", &size); err == nil {
reqData["key_size"] = size
}
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "create-issuer", reqData)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
issuerName := chi.URLParam(r, "issuer")
certPEM, err := ws.vault.GetIssuerCert(r.Context(), mountName, issuerName)
if err != nil {
http.Error(w, "issuer not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.pem", issuerName))
w.Write(certPEM)
}
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == "ca" {
return m.Name, nil
}
}
return "", fmt.Errorf("no CA engine mounted")
}
// grpcMessage extracts a human-readable message from a gRPC error.
func grpcMessage(err error) string {
if st, ok := status.FromError(err); ok {
return st.Message()
}
return err.Error()
}

View File

@@ -0,0 +1,112 @@
// Package webserver implements the standalone web UI server for Metacrypt.
// It communicates with the vault over gRPC and renders server-side HTML.
package webserver
import (
"context"
"crypto/tls"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
webui "git.wntrmute.dev/kyle/metacrypt/web"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
)
// WebServer is the standalone web UI server.
type WebServer struct {
cfg *config.Config
vault *VaultClient
logger *slog.Logger
httpSrv *http.Server
staticFS fs.FS
}
// New creates a new WebServer. It dials the vault gRPC endpoint.
func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert)
if err != nil {
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
}
staticFS, err := fs.Sub(webui.FS, "static")
if err != nil {
return nil, fmt.Errorf("webserver: static FS: %w", err)
}
return &WebServer{
cfg: cfg,
vault: vault,
logger: logger,
staticFS: staticFS,
}, nil
}
// Start starts the web server. It blocks until the server is closed.
func (ws *WebServer) Start() error {
r := chi.NewRouter()
ws.registerRoutes(r)
ws.httpSrv = &http.Server{
Addr: ws.cfg.Web.ListenAddr,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
ws.logger.Info("starting web server", "addr", ws.cfg.Web.ListenAddr)
if ws.cfg.Web.TLSCert != "" && ws.cfg.Web.TLSKey != "" {
ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
err := ws.httpSrv.ListenAndServeTLS(ws.cfg.Web.TLSCert, ws.cfg.Web.TLSKey)
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("webserver: %w", err)
}
return nil
}
err := ws.httpSrv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("webserver: %w", err)
}
return nil
}
// Shutdown gracefully shuts down the web server.
func (ws *WebServer) Shutdown(ctx context.Context) error {
_ = ws.vault.Close()
if ws.httpSrv != nil {
return ws.httpSrv.Shutdown(ctx)
}
return nil
}
func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
tmpl, err := template.ParseFS(webui.FS,
"templates/layout.html",
"templates/"+name,
)
if err != nil {
ws.logger.Error("parse template", "name", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
ws.logger.Error("execute template", "name", name, "error", err)
}
}
func extractCookie(r *http.Request) string {
c, err := r.Cookie("metacrypt_token")
if err != nil {
return ""
}
return c.Value
}

View File

@@ -0,0 +1,37 @@
package webserver
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"google.golang.org/protobuf/types/known/structpb"
)
type contextKey int
const tokenInfoCtxKey contextKey = iota
func withTokenInfo(ctx context.Context, info *TokenInfo) context.Context {
return context.WithValue(ctx, tokenInfoCtxKey, info)
}
func tokenInfoFromContext(ctx context.Context) *TokenInfo {
v, _ := ctx.Value(tokenInfoCtxKey).(*TokenInfo)
return v
}
// structFromMap converts a map[string]interface{} to a *structpb.Struct.
func structFromMap(m map[string]interface{}) (*structpb.Struct, error) {
return structpb.NewStruct(m)
}
// parsePEMCert decodes the first PEM block and parses it as an x509 certificate.
func parsePEMCert(pemData []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}