Files
metacrypt/internal/webserver/routes.go

607 lines
17 KiB
Go

package webserver
import (
"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("/{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 resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = resp["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
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "import-root", map[string]interface{}{
"cert_pem": certPEM,
"key_pem": keyPEM,
})
if 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
}
reqData := map[string]interface{}{"name": name}
if v := r.FormValue("expiry"); v != "" {
reqData["expiry"] = v
}
if v := r.FormValue("max_ttl"); v != "" {
reqData["max_ttl"] = v
}
if v := r.FormValue("key_algorithm"); v != "" {
reqData["key_algorithm"] = v
}
if v := r.FormValue("key_size"); v != "" {
var size float64
if _, err := fmt.Sscanf(v, "%f", &size); err == nil {
reqData["key_size"] = size
}
}
_, err = ws.vault.EngineRequest(r.Context(), token, mountName, "create-issuer", reqData)
if 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")
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-certs", nil)
if err != nil {
http.Error(w, "failed to list certificates", http.StatusInternalServerError)
return
}
nameFilter := strings.ToLower(r.URL.Query().Get("name"))
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
sortBy = "cn"
}
var certs []map[string]interface{}
if raw, ok := resp["certs"]; ok {
if list, ok := raw.([]interface{}); ok {
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
issuer, _ := m["issuer"].(string)
if issuer != issuerName {
continue
}
if nameFilter != "" {
cn, _ := m["cn"].(string)
if !strings.Contains(strings.ToLower(cn), nameFilter) {
continue
}
}
certs = append(certs, m)
}
}
}
}
// Sort: by expiry date or by common name (default).
if sortBy == "expiry" {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["expires_at"].(string)
b, _ := certs[j]["expires_at"].(string)
if a > b {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
} else {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["cn"].(string)
b, _ := certs[j]["cn"].(string)
if strings.ToLower(a) > strings.ToLower(b) {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
ws.renderTemplate(w, "issuer_detail.html", data)
}
func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
commonName := r.FormValue("common_name")
if commonName == "" {
ws.renderPKIWithError(w, r, mountName, info, "Common name is required")
return
}
issuer := r.FormValue("issuer")
if issuer == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
return
}
reqData := map[string]interface{}{
"common_name": commonName,
"issuer": issuer,
}
if v := r.FormValue("profile"); v != "" {
reqData["profile"] = v
}
if v := r.FormValue("ttl"); v != "" {
reqData["ttl"] = v
}
if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 {
reqData["dns_names"] = lines
}
if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 {
reqData["ip_addresses"] = lines
}
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "issue", reqData)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
// Re-render the PKI page with the issued certificate displayed.
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuedCert": resp,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
data["RootExpired"] = time.Now().After(cert.NotAfter)
data["HasRoot"] = true
}
}
if issuerResp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = issuerResp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"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 resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = resp["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()
}