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:
@@ -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
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
internal/webserver/client.go
Normal file
199
internal/webserver/client.go
Normal 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
|
||||
}
|
||||
430
internal/webserver/routes.go
Normal file
430
internal/webserver/routes.go
Normal 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()
|
||||
}
|
||||
112
internal/webserver/server.go
Normal file
112
internal/webserver/server.go
Normal 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
|
||||
}
|
||||
37
internal/webserver/util.go
Normal file
37
internal/webserver/util.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user