Files
metacrypt/internal/server/routes.go
Kyle Isom 658d067d78 Add architecture docs, fix gRPC/REST API parity, project conventions
- Add ARCHITECTURE.md with full system specification
- Add Project Structure and API Sync Rule to CLAUDE.md; ignore srv/
- Fix engine.proto MountRequest missing config field
- Add pki.proto PKIService to match unauthenticated REST PKI routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 23:29:51 -07:00

951 lines
26 KiB
Go

package server
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
func (s *Server) registerRoutes(r chi.Router) {
// Static files.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
// Web UI routes.
r.Get("/", s.handleWebRoot)
r.HandleFunc("/init", s.handleWebInit)
r.HandleFunc("/unseal", s.handleWebUnseal)
r.HandleFunc("/login", s.handleWebLogin)
r.Get("/dashboard", s.requireAuthWeb(s.handleWebDashboard))
r.Post("/dashboard/mount-ca", s.requireAuthWeb(s.handleWebDashboardMountCA))
r.Route("/pki", func(r chi.Router) {
r.Get("/", s.requireAuthWeb(s.handleWebPKI))
r.Post("/import-root", s.requireAuthWeb(s.handleWebImportRoot))
r.Post("/create-issuer", s.requireAuthWeb(s.handleWebCreateIssuer))
r.Get("/{issuer}", s.requireAuthWeb(s.handleWebPKIIssuer))
})
// API routes.
r.Get("/v1/status", s.handleStatus)
r.Post("/v1/init", s.handleInit)
r.Post("/v1/unseal", s.handleUnseal)
r.Post("/v1/seal", s.requireAdmin(s.handleSeal))
r.Post("/v1/auth/login", s.handleLogin)
r.Post("/v1/auth/logout", s.requireAuth(s.handleLogout))
r.Get("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
r.Get("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
r.Post("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
r.Post("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
r.Post("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
// Public PKI routes (no auth required, but must be unsealed).
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
}
// --- API Handlers ---
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"state": s.seal.State().String(),
})
}
func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Password == "" {
http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest)
return
}
params := crypto.Argon2Params{
Time: s.cfg.Seal.Argon2Time,
Memory: s.cfg.Seal.Argon2Memory,
Threads: s.cfg.Seal.Argon2Threads,
}
if err := s.seal.Initialize(r.Context(), []byte(req.Password), params); err != nil {
if err == seal.ErrAlreadyInitialized {
http.Error(w, `{"error":"already initialized"}`, http.StatusConflict)
return
}
s.logger.Error("init failed", "error", err)
http.Error(w, `{"error":"initialization failed"}`, http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"state": s.seal.State().String(),
})
}
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if err := s.seal.Unseal([]byte(req.Password)); err != nil {
switch err {
case seal.ErrNotInitialized:
http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed)
case seal.ErrInvalidPassword:
http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized)
case seal.ErrRateLimited:
http.Error(w, `{"error":"too many attempts, try again later"}`, http.StatusTooManyRequests)
case seal.ErrNotSealed:
http.Error(w, `{"error":"already unsealed"}`, http.StatusConflict)
default:
s.logger.Error("unseal failed", "error", err)
http.Error(w, `{"error":"unseal failed"}`, http.StatusInternalServerError)
}
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"state": s.seal.State().String(),
})
}
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
if err := s.engines.SealAll(); err != nil {
s.logger.Error("seal engines failed", "error", err)
}
if err := s.seal.Seal(); err != nil {
s.logger.Error("seal failed", "error", err)
http.Error(w, `{"error":"seal failed"}`, http.StatusInternalServerError)
return
}
s.auth.ClearCache()
writeJSON(w, http.StatusOK, map[string]interface{}{
"state": s.seal.State().String(),
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.seal.State() != seal.StateUnsealed {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
token, expiresAt, err := s.auth.Login(req.Username, req.Password, req.TOTPCode)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"expires_at": expiresAt,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
CACertPath: s.cfg.MCIAS.CACert,
Token: token,
})
if err == nil {
s.auth.Logout(client)
}
// Clear cookie.
http.SetCookie(w, &http.Cookie{
Name: "metacrypt_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": info.Username,
"roles": info.Roles,
"is_admin": info.IsAdmin,
})
}
func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) {
mounts := s.engines.ListMounts()
writeJSON(w, http.StatusOK, mounts)
}
func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Name == "" || req.Type == "" {
http.Error(w, `{"error":"name and type are required"}`, http.StatusBadRequest)
return
}
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if err := s.engines.Unmount(req.Name); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
var req struct {
Mount string `json:"mount"`
Operation string `json:"operation"`
Path string `json:"path"`
Data map[string]interface{} `json:"data"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Mount == "" || req.Operation == "" {
http.Error(w, `{"error":"mount and operation are required"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
engReq := &engine.Request{
Operation: req.Operation,
Path: req.Path,
Data: req.Data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
}
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "authentication required"):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
switch r.Method {
case http.MethodGet:
if !info.IsAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
rules, err := s.policy.ListRules(r.Context())
if err != nil {
s.logger.Error("list policies", "error", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if rules == nil {
rules = []policy.Rule{}
}
writeJSON(w, http.StatusOK, rules)
case http.MethodPost:
if !info.IsAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
var rule policy.Rule
if err := readJSON(r, &rule); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if rule.ID == "" {
http.Error(w, `{"error":"id is required"}`, http.StatusBadRequest)
return
}
if err := s.policy.CreateRule(r.Context(), &rule); err != nil {
s.logger.Error("create policy", "error", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, rule)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, `{"error":"id parameter required"}`, http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
rule, err := s.policy.GetRule(r.Context(), id)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, rule)
case http.MethodDelete:
if err := s.policy.DeleteRule(r.Context(), id); err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
// --- Public PKI Handlers ---
func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
certPEM, err := caEng.GetRootCertPEM()
if err != nil {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Write(certPEM)
}
func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
issuerName := r.URL.Query().Get("issuer")
if issuerName == "" {
http.Error(w, `{"error":"issuer query parameter required"}`, http.StatusBadRequest)
return
}
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
chainPEM, err := caEng.GetChainPEM(issuerName)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Write(chainPEM)
}
func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
issuerName := chi.URLParam(r, "name")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
certPEM, err := caEng.GetIssuerCertPEM(issuerName)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Write(certPEM)
}
func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
mount, err := s.engines.GetMount(mountName)
if err != nil {
return nil, err
}
if mount.Type != engine.EngineTypeCA {
return nil, errors.New("mount is not a CA engine")
}
caEng, ok := mount.Engine.(*ca.CAEngine)
if !ok {
return nil, errors.New("mount is not a CA engine")
}
return caEng, nil
}
// findCAMount returns the name of the first CA engine mount.
func (s *Server) findCAMount() (string, error) {
for _, m := range s.engines.ListMounts() {
if m.Type == engine.EngineTypeCA {
return m.Name, nil
}
}
return "", errors.New("no CA engine mounted")
}
// --- Web Handlers ---
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
state := s.seal.State()
switch state {
case seal.StateUninitialized:
http.Redirect(w, r, "/init", http.StatusFound)
case seal.StateSealed:
http.Redirect(w, r, "/unseal", http.StatusFound)
case seal.StateInitializing:
http.Redirect(w, r, "/init", http.StatusFound)
case seal.StateUnsealed:
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
}
func (s *Server) handleWebInit(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if s.seal.State() != seal.StateUninitialized {
http.Redirect(w, r, "/", http.StatusFound)
return
}
s.renderTemplate(w, "init.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if password == "" {
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
return
}
params := crypto.Argon2Params{
Time: s.cfg.Seal.Argon2Time,
Memory: s.cfg.Seal.Argon2Memory,
Threads: s.cfg.Seal.Argon2Threads,
}
if err := s.seal.Initialize(r.Context(), []byte(password), params); err != nil {
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": err.Error()})
return
}
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state := s.seal.State()
if state == seal.StateUninitialized {
http.Redirect(w, r, "/init", http.StatusFound)
return
}
if state == seal.StateUnsealed {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
s.renderTemplate(w, "unseal.html", nil)
case http.MethodPost:
r.ParseForm()
password := r.FormValue("password")
if err := s.seal.Unseal([]byte(password)); err != nil {
msg := "Invalid password"
if err == seal.ErrRateLimited {
msg = "Too many attempts. Please wait 60 seconds."
}
s.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 (s *Server) handleWebLogin(w http.ResponseWriter, r *http.Request) {
if s.seal.State() != seal.StateUnsealed {
http.Redirect(w, r, "/", http.StatusFound)
return
}
switch r.Method {
case http.MethodGet:
s.renderTemplate(w, "login.html", nil)
case http.MethodPost:
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
totpCode := r.FormValue("totp_code")
token, _, err := s.auth.Login(username, password, totpCode)
if err != nil {
s.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 (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
mounts := s.engines.ListMounts()
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": s.seal.State().String(),
})
}
func (s *Server) handleWebDashboardMountCA(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 == "" {
s.renderDashboardWithError(w, r, info, "Mount name is required")
return
}
config := map[string]interface{}{}
if org := r.FormValue("organization"); org != "" {
config["organization"] = org
}
// Optional root CA import.
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 != "" {
config["root_cert_pem"] = certPEM
config["root_key_pem"] = keyPEM
}
if err := s.engines.Mount(r.Context(), mountName, engine.EngineTypeCA, config); err != nil {
s.renderDashboardWithError(w, r, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) renderDashboardWithError(w http.ResponseWriter, _ *http.Request, info *auth.TokenInfo, errMsg string) {
mounts := s.engines.ListMounts()
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": s.seal.State().String(),
"MountError": errMsg,
})
}
func (s *Server) handleWebPKI(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
// Get root cert info.
rootPEM, err := caEng.GetRootCertPEM()
if err == nil && rootPEM != nil {
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
}
}
// Get issuers.
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-issuers",
CallerInfo: callerInfo,
})
if err == nil {
data["Issuers"] = resp.Data["issuers"]
}
s.renderTemplate(w, "pki.html", data)
}
func (s *Server) handleWebImportRoot(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mountName, err := s.findCAMount()
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")
// Also support file uploads.
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 == "" {
s.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required")
return
}
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
_, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "import-root",
CallerInfo: callerInfo,
Data: map[string]interface{}{
"cert_pem": certPEM,
"key_pem": keyPEM,
},
})
if err != nil {
s.renderPKIWithError(w, r, mountName, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) handleWebCreateIssuer(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.ParseForm()
name := r.FormValue("name")
if name == "" {
s.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
return
}
data := map[string]interface{}{
"name": name,
}
if v := r.FormValue("expiry"); v != "" {
data["expiry"] = v
}
if v := r.FormValue("max_ttl"); v != "" {
data["max_ttl"] = v
}
if v := r.FormValue("key_algorithm"); v != "" {
data["key_algorithm"] = v
}
if v := r.FormValue("key_size"); v != "" {
// Parse as float64 to match JSON number convention used by the engine.
var size float64
if _, err := fmt.Sscanf(v, "%f", &size); err == nil {
data["key_size"] = size
}
}
callerInfo := &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
}
_, err = s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "create-issuer",
CallerInfo: callerInfo,
Data: data,
})
if err != nil {
s.renderPKIWithError(w, r, mountName, info, err.Error())
return
}
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (s *Server) handleWebPKIIssuer(w http.ResponseWriter, r *http.Request) {
mountName, err := s.findCAMount()
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
issuerName := chi.URLParam(r, "issuer")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
certPEM, err := caEng.GetIssuerCertPEM(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 (s *Server) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *auth.TokenInfo, errMsg string) {
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
// Try to load existing root info.
mount, merr := s.engines.GetMount(mountName)
if merr == nil && mount.Type == engine.EngineTypeCA {
if caEng, ok := mount.Engine.(*ca.CAEngine); ok {
rootPEM, err := caEng.GetRootCertPEM()
if err == nil && rootPEM != nil {
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
}
}
}
}
s.renderTemplate(w, "pki.html", data)
}
func parsePEMCert(pemData []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
// requireAuthWeb redirects to login for web pages instead of returning 401.
func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.seal.State() != seal.StateUnsealed {
http.Redirect(w, r, "/", http.StatusFound)
return
}
token := extractToken(r)
if token == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
info, err := s.auth.ValidateToken(token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, tokenInfoKey, info)
next(w, r.WithContext(ctx))
}
}
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
tmpl, err := template.ParseFiles(
filepath.Join("web", "templates", "layout.html"),
filepath.Join("web", "templates", name),
)
if err != nil {
s.logger.Error("parse template", "name", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
s.logger.Error("execute template", "name", name, "error", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func readJSON(r *http.Request, v interface{}) error {
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
return err
}
return json.Unmarshal(body, v)
}