Checkpoint: grpc auth fix, issuer list/detail, v2 protos, architecture docs

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-03-15 11:39:13 -07:00
parent d0b1875dbb
commit ad167aed9b
41 changed files with 1080 additions and 219 deletions

View File

@@ -412,7 +412,7 @@ func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*
return &engine.Response{
Data: map[string]interface{}{
"cn": newCert.Subject.CommonName,
"expires_at": newCert.NotAfter,
"expires_at": newCert.NotAfter.Format(time.RFC3339),
},
}, nil
}
@@ -639,7 +639,7 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e
return nil, ErrSealed
}
names := make([]string, 0, len(e.issuers))
names := make([]interface{}, 0, len(e.issuers))
for name := range e.issuers {
names = append(names, name)
}
@@ -795,7 +795,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
"cn": cn,
"sans": allSANs,
"issued_by": req.CallerInfo.Username,
"expires_at": leafCert.NotAfter,
"expires_at": leafCert.NotAfter.Format(time.RFC3339),
},
}, nil
}
@@ -838,8 +838,8 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
"profile": record.Profile,
"cert_pem": record.CertPEM,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
"issued_at": record.IssuedAt.Format(time.RFC3339),
"expires_at": record.ExpiresAt.Format(time.RFC3339),
},
}, nil
}
@@ -857,7 +857,7 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
return nil, fmt.Errorf("ca: list certs: %w", err)
}
var certs []map[string]interface{}
var certs []interface{}
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
@@ -876,8 +876,8 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
"cn": record.CN,
"profile": record.Profile,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
"issued_at": record.IssuedAt.Format(time.RFC3339),
"expires_at": record.ExpiresAt.Format(time.RFC3339),
})
}
@@ -1009,7 +1009,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
"key_pem": string(newKeyPEM),
"chain_pem": string(chainPEM),
"cn": record.CN,
"expires_at": newCert.NotAfter,
"expires_at": newCert.NotAfter.Format(time.RFC3339),
},
}, nil
}

View File

@@ -566,7 +566,7 @@ func TestGetAndListCerts(t *testing.T) {
t.Fatalf("list-certs: %v", err)
}
certs, ok := listResp.Data["certs"].([]map[string]interface{})
certs, ok := listResp.Data["certs"].([]interface{})
if !ok {
t.Fatalf("certs type: %T", listResp.Data["certs"])
}
@@ -575,7 +575,7 @@ func TestGetAndListCerts(t *testing.T) {
}
// Get a specific cert.
serial := certs[0]["serial"].(string) //nolint:errcheck
serial := certs[0].(map[string]interface{})["serial"].(string) //nolint:errcheck
getResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "get-cert",
CallerInfo: userCaller(),

View File

@@ -90,6 +90,12 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p
}
}
username := ""
if ti != nil {
username = ti.Username
}
es.s.logger.Info("grpc: engine execute", "mount", req.Mount, "operation", req.Operation, "username", username)
resp, err := es.s.engines.HandleRequest(ctx, req.Mount, engReq)
if err != nil {
st := codes.Internal
@@ -101,8 +107,10 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p
case strings.Contains(err.Error(), "not found"):
st = codes.NotFound
}
es.s.logger.Error("grpc: engine execute failed", "mount", req.Mount, "operation", req.Operation, "username", username, "error", err)
return nil, status.Error(st, err.Error())
}
es.s.logger.Info("grpc: engine execute ok", "mount", req.Mount, "operation", req.Operation, "username", username)
pbData, err := structpb.NewStruct(resp.Data)
if err != nil {

View File

@@ -111,7 +111,7 @@ func sealRequiredMethods() map[string]bool {
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.EngineService/Execute": true,
"/metacrypt.v1.PKIService/GetRootCert": true,
"/metacrypt.v1.PKIService/GetChain": true,
"/metacrypt.v1.PKIService/GetIssuerCert": true,
@@ -134,7 +134,7 @@ func authRequiredMethods() map[string]bool {
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.EngineService/Execute": true,
"/metacrypt.v1.PolicyService/CreatePolicy": true,
"/metacrypt.v1.PolicyService/ListPolicies": true,
"/metacrypt.v1.PolicyService/GetPolicy": true,

View File

@@ -1,126 +0,0 @@
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
grpcstatus "google.golang.org/grpc/status"
metacryptv1 "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
// systemServiceServer implements metacryptv1.SystemServiceServer.
type systemServiceServer struct {
metacryptv1.UnimplementedSystemServiceServer
s *Server
}
func (g *systemServiceServer) Status(_ context.Context, _ *metacryptv1.StatusRequest) (*metacryptv1.StatusResponse, error) {
return &metacryptv1.StatusResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Init(ctx context.Context, req *metacryptv1.InitRequest) (*metacryptv1.InitResponse, error) {
params := crypto.Argon2Params{
Time: g.s.cfg.Seal.Argon2Time,
Memory: g.s.cfg.Seal.Argon2Memory,
Threads: g.s.cfg.Seal.Argon2Threads,
}
if err := g.s.seal.Initialize(ctx, []byte(req.Password), params); err != nil {
if errors.Is(err, seal.ErrAlreadyInitialized) {
return nil, grpcstatus.Error(codes.AlreadyExists, "already initialized")
}
g.s.logger.Error("grpc init failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "initialization failed")
}
return &metacryptv1.InitResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Unseal(ctx context.Context, req *metacryptv1.UnsealRequest) (*metacryptv1.UnsealResponse, error) {
if err := g.s.seal.Unseal([]byte(req.Password)); err != nil {
switch {
case errors.Is(err, seal.ErrNotInitialized):
return nil, grpcstatus.Error(codes.FailedPrecondition, "not initialized")
case errors.Is(err, seal.ErrInvalidPassword):
return nil, grpcstatus.Error(codes.Unauthenticated, "invalid password")
case errors.Is(err, seal.ErrRateLimited):
return nil, grpcstatus.Error(codes.ResourceExhausted, "too many attempts, try again later")
case errors.Is(err, seal.ErrNotSealed):
return nil, grpcstatus.Error(codes.AlreadyExists, "already unsealed")
default:
g.s.logger.Error("grpc unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "unseal failed")
}
}
if err := g.s.engines.UnsealAll(ctx); err != nil {
g.s.logger.Error("grpc engine unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "engine unseal failed")
}
return &metacryptv1.UnsealResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Seal(_ context.Context, _ *metacryptv1.SealRequest) (*metacryptv1.SealResponse, error) {
if err := g.s.engines.SealAll(); err != nil {
g.s.logger.Error("grpc seal engines failed", "error", err)
}
if err := g.s.seal.Seal(); err != nil {
g.s.logger.Error("grpc seal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "seal failed")
}
g.s.auth.ClearCache()
return &metacryptv1.SealResponse{State: g.s.seal.State().String()}, nil
}
// StartGRPC starts the gRPC server on cfg.Server.GRPCAddr using the same TLS
// certificate as the HTTP server. It blocks until the listener closes.
func (s *Server) StartGRPC() error {
if s.cfg.Server.GRPCAddr == "" {
return nil
}
cert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("grpc: load TLS key pair: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
metacryptv1.RegisterSystemServiceServer(grpcSrv, &systemServiceServer{s: s})
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("grpc: listen: %w", err)
}
s.grpcSrv = grpcSrv
s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr)
if err := grpcSrv.Serve(lis); err != nil {
return fmt.Errorf("grpc: serve: %w", err)
}
return nil
}
// ShutdownGRPC gracefully stops the gRPC server.
func (s *Server) ShutdownGRPC() {
if s.grpcSrv != nil {
s.grpcSrv.GracefulStop()
}
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"google.golang.org/grpc"
@@ -24,9 +25,12 @@ type VaultClient struct {
}
// NewVaultClient dials the vault gRPC server and returns a client.
func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) {
logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath)
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
logger.Debug("loading vault CA certificate", "path", caCertPath)
pemData, err := os.ReadFile(caCertPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("webserver: read CA cert: %w", err)
@@ -36,12 +40,17 @@ func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
return nil, fmt.Errorf("webserver: parse CA cert")
}
tlsCfg.RootCAs = pool
logger.Debug("vault CA certificate loaded successfully")
} else {
logger.Debug("no CA cert configured, using system roots")
}
logger.Debug("dialing vault gRPC", "addr", addr)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("webserver: dial vault: %w", err)
}
logger.Debug("vault gRPC connection established", "addr", addr)
return &VaultClient{
conn: conn,

View File

@@ -12,6 +12,17 @@ import (
"google.golang.org/grpc/status"
)
// splitLines splits a newline-delimited string into non-empty trimmed lines.
func splitLines(s string) []interface{} {
var out []interface{}
for _, line := range strings.Split(s, "\n") {
if v := strings.TrimSpace(line); v != "" {
out = append(out, v)
}
}
return out
}
func (ws *WebServer) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS))))
@@ -26,6 +37,8 @@ func (ws *WebServer) registerRoutes(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.Post("/issue", ws.requireAuth(ws.handleIssueCert))
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
@@ -394,7 +407,159 @@ func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(certPEM) //nolint:gosec
}
func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
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")
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-certs", nil)
if err != nil {
http.Error(w, "failed to list certificates", http.StatusInternalServerError)
return
}
nameFilter := strings.ToLower(r.URL.Query().Get("name"))
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
sortBy = "cn"
}
var certs []map[string]interface{}
if raw, ok := resp["certs"]; ok {
if list, ok := raw.([]interface{}); ok {
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
issuer, _ := m["issuer"].(string)
if issuer != issuerName {
continue
}
if nameFilter != "" {
cn, _ := m["cn"].(string)
if !strings.Contains(strings.ToLower(cn), nameFilter) {
continue
}
}
certs = append(certs, m)
}
}
}
}
// Sort: by expiry date or by common name (default).
if sortBy == "expiry" {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["expires_at"].(string)
b, _ := certs[j]["expires_at"].(string)
if a > b {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
} else {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["cn"].(string)
b, _ := certs[j]["cn"].(string)
if strings.ToLower(a) > strings.ToLower(b) {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
ws.renderTemplate(w, "issuer_detail.html", data)
}
func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
commonName := r.FormValue("common_name")
if commonName == "" {
ws.renderPKIWithError(w, r, mountName, info, "Common name is required")
return
}
issuer := r.FormValue("issuer")
if issuer == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
return
}
reqData := map[string]interface{}{
"common_name": commonName,
"issuer": issuer,
}
if v := r.FormValue("profile"); v != "" {
reqData["profile"] = v
}
if v := r.FormValue("ttl"); v != "" {
reqData["ttl"] = v
}
if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 {
reqData["dns_names"] = lines
}
if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 {
reqData["ip_addresses"] = lines
}
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "issue", reqData)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
// Re-render the PKI page with the issued certificate displayed.
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuedCert": resp,
}
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 issuerResp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = issuerResp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
@@ -412,6 +577,9 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request,
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)
}

View File

@@ -29,10 +29,12 @@ type WebServer struct {
// 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)
logger.Info("connecting to vault", "addr", cfg.Web.VaultGRPC, "ca_cert", cfg.Web.VaultCACert)
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert, logger)
if err != nil {
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
}
logger.Info("vault connection ready", "addr", cfg.Web.VaultGRPC)
staticFS, err := fs.Sub(webui.FS, "static")
if err != nil {
@@ -47,9 +49,37 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
}, nil
}
// loggingMiddleware logs each incoming HTTP request.
func (ws *WebServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(lw, r)
ws.logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", lw.status,
"duration", time.Since(start),
"remote_addr", r.RemoteAddr,
)
})
}
// loggingResponseWriter wraps http.ResponseWriter to capture the status code.
type loggingResponseWriter struct {
http.ResponseWriter
status int
}
func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.status = code
lw.ResponseWriter.WriteHeader(code)
}
// Start starts the web server. It blocks until the server is closed.
func (ws *WebServer) Start() error {
r := chi.NewRouter()
r.Use(ws.loggingMiddleware)
ws.registerRoutes(r)
ws.httpSrv = &http.Server{