Add certificate issuance, CSR signing, and cert listing to web UI

- Add SignCSR RPC to v2 CA proto and regenerate; implement handleSignCSR
  in CA engine and caServer gRPC layer; add SignCSR client method and
  POST /pki/sign-csr web route with result display in pki.html
- Fix issuer detail cert listing: template was using map-style index on
  CertSummary structs; switch to struct field access and populate
  IssuedBy/IssuedAt fields from proto response
- Add certificate detail view (cert_detail.html) with GET /cert/{serial}
  and GET /cert/{serial}/download routes
- Update Makefile proto target to generate both v1 and v2 protos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 13:21:13 -07:00
parent 65c92fe5ec
commit b4dbc088cb
12 changed files with 785 additions and 82 deletions

View File

@@ -278,6 +278,87 @@ func (c *VaultClient) IssueCert(ctx context.Context, token string, req IssueCert
return issued, nil
}
// SignCSRRequest holds parameters for signing an external CSR.
type SignCSRRequest struct {
Mount string
Issuer string
CSRPEM string
Profile string
TTL string
}
// SignedCert holds the result of signing a CSR.
type SignedCert struct {
Serial string
CertPEM string
ChainPEM string
ExpiresAt string
}
// SignCSR signs an externally generated CSR with the named issuer.
func (c *VaultClient) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) {
resp, err := c.ca.SignCSR(withToken(ctx, token), &pb.SignCSRRequest{
Mount: req.Mount,
Issuer: req.Issuer,
CsrPem: []byte(req.CSRPEM),
Profile: req.Profile,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
sc := &SignedCert{
Serial: resp.Serial,
CertPEM: string(resp.CertPem),
ChainPEM: string(resp.ChainPem),
}
if resp.ExpiresAt != nil {
sc.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return sc, nil
}
// CertDetail holds the full certificate record for the detail view.
type CertDetail struct {
Serial string
Issuer string
CommonName string
SANs []string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
CertPEM string
}
// GetCert retrieves a full certificate record by serial number.
func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) {
resp, err := c.ca.GetCert(withToken(ctx, token), &pb.GetCertRequest{Mount: mount, Serial: serial})
if err != nil {
return nil, err
}
rec := resp.GetCert()
if rec == nil {
return nil, fmt.Errorf("cert not found")
}
cd := &CertDetail{
Serial: rec.Serial,
Issuer: rec.Issuer,
CommonName: rec.CommonName,
SANs: rec.Sans,
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
CertPEM: string(rec.CertPem),
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// CertSummary holds lightweight certificate metadata for list views.
type CertSummary struct {
Serial string

View File

@@ -1,6 +1,8 @@
package webserver
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
@@ -39,6 +41,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
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("/cert/{serial}", ws.requireAuth(ws.handleCertDetail))
r.Get("/cert/{serial}/download", ws.requireAuth(ws.handleCertDownload))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
@@ -526,12 +530,140 @@ func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
return
}
// Re-render the PKI page with the issued certificate displayed.
// Stream a tgz archive containing the private key (PKCS8) and certificate.
filename := issuedCert.Serial + ".tgz"
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
gw := gzip.NewWriter(w)
tw := tar.NewWriter(gw)
writeTarFile := func(name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0600,
Size: int64(len(data)),
ModTime: time.Now(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := writeTarFile("key.pem", []byte(issuedCert.KeyPEM)); err != nil {
ws.logger.Error("write key to tgz", "error", err)
return
}
if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil {
ws.logger.Error("write cert to tgz", "error", err)
return
}
_ = tw.Close()
_ = gw.Close()
}
func (ws *WebServer) handleCertDetail(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
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Cert": cert,
})
}
func (ws *WebServer) handleCertDownload(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
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", "attachment; filename=\""+serial+".pem\"")
_, _ = w.Write([]byte(cert.CertPEM))
}
func (ws *WebServer) handleSignCSR(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()
issuer := r.FormValue("issuer")
if issuer == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
return
}
csrPEM := r.FormValue("csr_pem")
if csrPEM == "" {
ws.renderPKIWithError(w, r, mountName, info, "CSR PEM is required")
return
}
req := SignCSRRequest{
Mount: mountName,
Issuer: issuer,
CSRPEM: csrPEM,
Profile: r.FormValue("profile"),
TTL: r.FormValue("ttl"),
}
signed, err := ws.vault.SignCSR(r.Context(), token, req)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuedCert": issuedCert,
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"SignedCert": signed,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {