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

@@ -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{