The vault server holds in-memory unsealed state (KEK, engine keys) that is lost on restart, requiring a full unseal ceremony. Previously the web UI ran inside the vault process, so any UI change forced a restart and re-unseal. This change extracts the web UI into a separate metacrypt-web binary that communicates with the vault over an authenticated gRPC connection. The web server carries no sealed state and can be restarted freely. - gen/metacrypt/v1/: generated Go bindings from proto/metacrypt/v1/ - internal/grpcserver/: full gRPC server implementation (System, Auth, Engine, PKI, Policy, ACME services) with seal/auth/admin interceptors - internal/webserver/: web server with gRPC vault client; templates embedded via web/embed.go (no runtime web/ directory needed) - cmd/metacrypt-web/: standalone binary entry point - internal/config: added [web] section (listen_addr, vault_grpc, etc.) - internal/server/routes.go: removed all web UI routes and handlers - cmd/metacrypt/server.go: starts gRPC server alongside HTTP server - Deploy: Dockerfile builds both binaries, docker-compose adds metacrypt-web service, new metacrypt-web.service systemd unit, Makefile gains proto/metacrypt-web targets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
431 lines
12 KiB
Go
431 lines
12 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"
|
|
)
|
|
|
|
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.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.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.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.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
|
|
}
|
|
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
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 f.Close()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
certPEM = string(data)
|
|
}
|
|
if f, _, err := r.FormFile("key_file"); err == nil {
|
|
defer 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
|
|
}
|
|
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
r.ParseForm()
|
|
}
|
|
|
|
certPEM := r.FormValue("cert_pem")
|
|
keyPEM := r.FormValue("key_pem")
|
|
if certPEM == "" {
|
|
if f, _, err := r.FormFile("cert_file"); err == nil {
|
|
defer 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 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.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)
|
|
}
|
|
|
|
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
|
|
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
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|