All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1043 lines
30 KiB
Go
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/mc/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()
|
|
}
|