- 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>
730 lines
20 KiB
Go
730 lines
20 KiB
Go
package webserver
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"google.golang.org/grpc/codes"
|
|
"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))))
|
|
|
|
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.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))
|
|
})
|
|
}
|
|
|
|
// 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.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = 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.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = 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.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = 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
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = 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 func() { _ = f.Close() }()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
certPEM = string(data)
|
|
}
|
|
if f, _, err := r.FormFile("key_file"); err == nil {
|
|
defer func() { _ = 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 issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = 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
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
}
|
|
|
|
certPEM := r.FormValue("cert_pem")
|
|
keyPEM := r.FormValue("key_pem")
|
|
if certPEM == "" {
|
|
if f, _, err := r.FormFile("cert_file"); err == nil {
|
|
defer func() { _ = 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 func() { _ = 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
|
|
}
|
|
|
|
if err = ws.vault.ImportRoot(r.Context(), token, mountName, certPEM, keyPEM); 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.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
|
|
return
|
|
}
|
|
|
|
issuerReq := CreateIssuerRequest{
|
|
Mount: mountName,
|
|
Name: name,
|
|
}
|
|
if v := r.FormValue("expiry"); v != "" {
|
|
issuerReq.Expiry = v
|
|
}
|
|
if v := r.FormValue("max_ttl"); v != "" {
|
|
issuerReq.MaxTTL = v
|
|
}
|
|
if v := r.FormValue("key_algorithm"); v != "" {
|
|
issuerReq.KeyAlgorithm = v
|
|
}
|
|
if v := r.FormValue("key_size"); v != "" {
|
|
var size int32
|
|
if _, err := fmt.Sscanf(v, "%d", &size); err == nil {
|
|
issuerReq.KeySize = size
|
|
}
|
|
}
|
|
|
|
if err = ws.vault.CreateIssuer(r.Context(), token, issuerReq); 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) //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")
|
|
|
|
allCerts, err := ws.vault.ListCerts(r.Context(), token, mountName)
|
|
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 []CertSummary
|
|
for _, cs := range allCerts {
|
|
if cs.Issuer != issuerName {
|
|
continue
|
|
}
|
|
if nameFilter != "" && !strings.Contains(strings.ToLower(cs.CommonName), strings.ToLower(nameFilter)) {
|
|
continue
|
|
}
|
|
certs = append(certs, cs)
|
|
}
|
|
|
|
// 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-- {
|
|
if certs[j-1].ExpiresAt > certs[j].ExpiresAt {
|
|
certs[j-1], certs[j] = certs[j], certs[j-1]
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for i := 1; i < len(certs); i++ {
|
|
for j := i; j > 0; j-- {
|
|
if strings.ToLower(certs[j-1].CommonName) > strings.ToLower(certs[j].CommonName) {
|
|
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
|
|
}
|
|
|
|
certReq := IssueCertRequest{
|
|
Mount: mountName,
|
|
Issuer: issuer,
|
|
CommonName: commonName,
|
|
}
|
|
if v := r.FormValue("profile"); v != "" {
|
|
certReq.Profile = v
|
|
}
|
|
if v := r.FormValue("ttl"); v != "" {
|
|
certReq.TTL = v
|
|
}
|
|
if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 {
|
|
for _, l := range lines {
|
|
certReq.DNSNames = append(certReq.DNSNames, l.(string))
|
|
}
|
|
}
|
|
if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 {
|
|
for _, l := range lines {
|
|
certReq.IPAddresses = append(certReq.IPAddresses, l.(string))
|
|
}
|
|
}
|
|
certReq.KeyUsages = r.Form["key_usages"]
|
|
certReq.ExtKeyUsages = r.Form["ext_key_usages"]
|
|
|
|
issuedCert, err := ws.vault.IssueCert(r.Context(), token, certReq)
|
|
if err != nil {
|
|
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
"SignedCert": signed,
|
|
}
|
|
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 issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = 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,
|
|
"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
|
|
}
|
|
}
|
|
if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = issuers
|
|
}
|
|
|
|
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()
|
|
}
|