Files
metacrypt/internal/webserver/routes.go
Kyle Isom 806f63957b Migrate CSRF, web templates, session cookies, and snapshot to mcdsl
CSRF: Replace local csrfProtect with mcdsl/csrf.Protect. Delete
internal/webserver/csrf.go.

Web: Replace renderTemplate with web.RenderTemplate + csrf.TemplateFunc.
Replace extractCookie with web.GetSessionToken. Replace manual session
cookie SetCookie with web.SetSessionCookie.

Snapshot: Replace local sqliteBackup with mcdsl/db.Snapshot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:14:11 -07:00

1043 lines
30 KiB
Go

package webserver
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/mcdsl/web"
)
// 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.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
r.Route("/policy", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePolicy))
r.Post("/", ws.requireAuth(ws.handlePolicyCreate))
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
})
r.Route("/sshca", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleSSHCA))
r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser))
r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail))
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleSSHCACertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleSSHCACertDelete))
r.Post("/profile/create", ws.requireAuth(ws.handleSSHCACreateProfile))
r.Get("/profile/{name}", ws.requireAuth(ws.handleSSHCAProfileDetail))
r.Post("/profile/{name}/update", ws.requireAuth(ws.handleSSHCAUpdateProfile))
r.Post("/profile/{name}/delete", ws.requireAuth(ws.handleSSHCADeleteProfile))
})
r.Route("/transit", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleTransit))
r.Get("/key/{name}", ws.requireAuth(ws.handleTransitKeyDetail))
r.Post("/key/create", ws.requireAuth(ws.handleTransitCreateKey))
r.Post("/key/{name}/rotate", ws.requireAuth(ws.handleTransitRotateKey))
r.Post("/key/{name}/config", ws.requireAuth(ws.handleTransitUpdateConfig))
r.Post("/key/{name}/trim", ws.requireAuth(ws.handleTransitTrimKey))
r.Post("/key/{name}/delete", ws.requireAuth(ws.handleTransitDeleteKey))
r.Post("/encrypt", ws.requireAuth(ws.handleTransitEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleTransitDecrypt))
r.Post("/rewrap", ws.requireAuth(ws.handleTransitRewrap))
r.Post("/sign", ws.requireAuth(ws.handleTransitSign))
r.Post("/verify", ws.requireAuth(ws.handleTransitVerify))
r.Post("/hmac", ws.requireAuth(ws.handleTransitHMAC))
})
r.Route("/user", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleUser))
r.Post("/register", ws.requireAuth(ws.handleUserRegister))
r.Post("/provision", ws.requireAuth(ws.handleUserProvision))
r.Get("/key/{username}", ws.requireAuth(ws.handleUserKeyDetail))
r.Post("/encrypt", ws.requireAuth(ws.handleUserEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleUserDecrypt))
r.Post("/re-encrypt", ws.requireAuth(ws.handleUserReEncrypt))
r.Post("/rotate", ws.requireAuth(ws.handleUserRotateKey))
r.Post("/delete/{username}", ws.requireAuth(ws.handleUserDeleteUser))
})
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.Post("/sign-csr", ws.requireAuth(ws.handleSignCSR))
r.Get("/download/{token}", ws.requireAuth(ws.handleTGZDownload))
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.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleCertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleCertDelete))
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
}
info.Username = ws.resolveUser(info.Username)
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
}
web.SetSessionCookie(w, "metacrypt_token", token)
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())
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
ws.renderTemplate(w, "dashboard.html", data)
}
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) handleDashboardMountEngine(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)
_ = r.ParseForm()
mountName := r.FormValue("name")
engineType := r.FormValue("type")
if mountName == "" || engineType == "" {
ws.renderDashboardWithError(w, r, info, "Mount name and engine type are required")
return
}
cfg := map[string]interface{}{}
if v := r.FormValue("key_algorithm"); v != "" {
cfg["key_algorithm"] = v
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, engineType, cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
// Redirect to the appropriate engine page.
switch engineType {
case "sshca":
http.Redirect(w, r, "/sshca", http.StatusFound)
case "transit":
http.Redirect(w, r, "/transit", http.StatusFound)
case "user":
http.Redirect(w, r, "/user", http.StatusFound)
default:
http.Redirect(w, r, "/dashboard", 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())
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
data["MountError"] = errMsg
ws.renderTemplate(w, "dashboard.html", data)
}
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 := ws.baseData(r, info)
data["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]
}
}
}
}
for i := range certs {
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["IssuerName"] = issuerName
data["Certs"] = certs
data["NameFilter"] = r.URL.Query().Get("name")
data["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
}
// Build the tgz archive in memory, store it in the cache, then redirect
// the browser to the one-time download URL so the archive is only served
// once and then discarded.
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
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("build tgz key", "error", err)
http.Error(w, "failed to build archive", http.StatusInternalServerError)
return
}
if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil {
ws.logger.Error("build tgz cert", "error", err)
http.Error(w, "failed to build archive", http.StatusInternalServerError)
return
}
_ = tw.Close()
_ = gw.Close()
// Generate a random one-time token for the download URL.
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
ws.logger.Error("generate download token", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
dlToken := hex.EncodeToString(raw[:])
ws.tgzCache.Store(dlToken, &tgzEntry{
filename: issuedCert.Serial + ".tgz",
data: buf.Bytes(),
})
http.Redirect(w, r, "/pki/download/"+dlToken, http.StatusSeeOther)
}
func (ws *WebServer) handleTGZDownload(w http.ResponseWriter, r *http.Request) {
dlToken := chi.URLParam(r, "token")
val, ok := ws.tgzCache.LoadAndDelete(dlToken)
if !ok {
http.Error(w, "download not found or already used", http.StatusNotFound)
return
}
entry := val.(*tgzEntry)
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename=\""+entry.filename+"\"")
_, _ = w.Write(entry.data)
}
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
}
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "cert_detail.html", data)
}
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) handleCertRevoke(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
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.RevokeCert(r.Context(), token, mountName, serial); 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
}
http.Redirect(w, r, "/pki/cert/"+serial, http.StatusSeeOther)
}
func (ws *WebServer) handleCertDelete(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
}
serial := chi.URLParam(r, "serial")
// Fetch the cert to get the issuer for the redirect.
cert, certErr := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err := ws.vault.DeleteCert(r.Context(), token, mountName, serial); 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
}
if certErr == nil && cert != nil {
http.Redirect(w, r, "/pki/issuer/"+cert.Issuer, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/pki", http.StatusSeeOther)
}
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 := ws.baseData(r, info)
data["MountName"] = mountName
data["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 := ws.baseData(r, info)
data["MountName"] = mountName
data["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) {
return ws.findMount(r, token, "ca")
}
func (ws *WebServer) findSSHCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "sshca")
}
func (ws *WebServer) findTransitMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "transit")
}
func (ws *WebServer) findUserMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "user")
}
func (ws *WebServer) findMount(r *http.Request, token, engineType string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == engineType {
return m.Name, nil
}
}
return "", fmt.Errorf("no %s engine mounted", engineType)
}
// mountTypes returns a set of engine types that are currently mounted.
func (ws *WebServer) mountTypes(r *http.Request, token string) map[string]bool {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return nil
}
types := make(map[string]bool, len(mounts))
for _, m := range mounts {
types[m.Type] = true
}
return types
}
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
rules, err := ws.vault.ListPolicies(r.Context(), token)
if err != nil {
rules = []PolicyRule{}
}
data := ws.baseData(r, info)
data["Rules"] = rules
ws.renderTemplate(w, "policy.html", data)
}
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
_ = r.ParseForm()
priorityStr := r.FormValue("priority")
priority := 50
if priorityStr != "" {
if p, err := strconv.Atoi(priorityStr); err == nil {
priority = p
}
}
splitCSV := func(s string) []string {
var out []string
for _, v := range strings.Split(s, ",") {
v = strings.TrimSpace(v)
if v != "" {
out = append(out, v)
}
}
return out
}
rule := PolicyRule{
ID: r.FormValue("id"),
Priority: priority,
Effect: r.FormValue("effect"),
Usernames: splitCSV(r.FormValue("usernames")),
Roles: splitCSV(r.FormValue("roles")),
Resources: splitCSV(r.FormValue("resources")),
Actions: splitCSV(r.FormValue("actions")),
}
if rule.ID == "" || rule.Effect == "" {
ws.renderPolicyWithError(w, r, info, token, "ID and effect are required")
return
}
if _, err := ws.vault.CreatePolicy(r.Context(), token, rule); err != nil {
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
return
}
http.Redirect(w, r, "/policy", http.StatusFound)
}
func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
_ = r.ParseForm()
id := r.FormValue("id")
if id == "" {
http.Redirect(w, r, "/policy", http.StatusFound)
return
}
if err := ws.vault.DeletePolicy(r.Context(), token, id); err != nil {
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
return
}
http.Redirect(w, r, "/policy", http.StatusFound)
}
func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) {
rules, _ := ws.vault.ListPolicies(r.Context(), token)
data := ws.baseData(r, info)
data["Rules"] = rules
data["Error"] = errMsg
ws.renderTemplate(w, "policy.html", data)
}
// baseData returns a template data map pre-populated with user info and nav flags.
func (ws *WebServer) baseData(r *http.Request, info *TokenInfo) map[string]interface{} {
token := extractCookie(r)
types := ws.mountTypes(r, token)
return map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"HasSSHCA": types["sshca"],
"HasTransit": types["transit"],
"HasUser": types["user"],
}
}
// 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()
}