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>
This commit is contained in:
@@ -2,16 +2,23 @@ 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"
|
||||
@@ -19,58 +26,58 @@ import (
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||
)
|
||||
|
||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
func (s *Server) registerRoutes(r chi.Router) {
|
||||
// Static files.
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
|
||||
// Web UI routes.
|
||||
mux.HandleFunc("/", s.handleWebRoot)
|
||||
mux.HandleFunc("/init", s.handleWebInit)
|
||||
mux.HandleFunc("/unseal", s.handleWebUnseal)
|
||||
mux.HandleFunc("/login", s.handleWebLogin)
|
||||
mux.HandleFunc("/dashboard", s.requireAuthWeb(s.handleWebDashboard))
|
||||
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.
|
||||
mux.HandleFunc("/v1/status", s.handleStatus)
|
||||
mux.HandleFunc("/v1/init", s.handleInit)
|
||||
mux.HandleFunc("/v1/unseal", s.handleUnseal)
|
||||
mux.HandleFunc("/v1/seal", s.requireAdmin(s.handleSeal))
|
||||
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))
|
||||
|
||||
mux.HandleFunc("/v1/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("/v1/auth/logout", s.requireAuth(s.handleLogout))
|
||||
mux.HandleFunc("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
|
||||
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))
|
||||
|
||||
mux.HandleFunc("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
|
||||
mux.HandleFunc("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
|
||||
mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
||||
mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
||||
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).
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
|
||||
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))
|
||||
|
||||
mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
||||
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||
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) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"state": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -104,10 +111,6 @@ func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -139,11 +142,6 @@ func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.engines.SealAll(); err != nil {
|
||||
s.logger.Error("seal engines failed", "error", err)
|
||||
}
|
||||
@@ -161,10 +159,6 @@ func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.seal.State() != seal.StateUnsealed {
|
||||
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -193,10 +187,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token := extractToken(r)
|
||||
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
|
||||
CACertPath: s.cfg.MCIAS.CACert,
|
||||
@@ -221,10 +211,6 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"username": info.Username,
|
||||
@@ -234,19 +220,11 @@ func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mounts := s.engines.ListMounts()
|
||||
writeJSON(w, http.StatusOK, mounts)
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -270,10 +248,6 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -289,11 +263,6 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Mount string `json:"mount"`
|
||||
Operation string `json:"operation"`
|
||||
@@ -324,7 +293,6 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
// Map known errors to appropriate status codes.
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrMountNotFound):
|
||||
status = http.StatusNotFound
|
||||
@@ -342,93 +310,6 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp.Data)
|
||||
}
|
||||
|
||||
// --- Public PKI Handlers ---
|
||||
|
||||
func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
|
||||
mountName := r.PathValue("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 := r.PathValue("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 := r.PathValue("mount")
|
||||
issuerName := r.PathValue("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
|
||||
}
|
||||
|
||||
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
switch r.Method {
|
||||
@@ -504,13 +385,106 @@ func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
state := s.seal.State()
|
||||
switch state {
|
||||
case seal.StateUninitialized:
|
||||
@@ -628,6 +602,299 @@ func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
@@ -43,20 +45,23 @@ func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenti
|
||||
|
||||
// Start starts the HTTPS server.
|
||||
func (s *Server) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
s.registerRoutes(mux)
|
||||
r := chi.NewRouter()
|
||||
r.Use(s.loggingMiddleware)
|
||||
s.registerRoutes(r)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
}
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: s.cfg.Server.ListenAddr,
|
||||
Handler: s.loggingMiddleware(mux),
|
||||
Handler: r,
|
||||
TLSConfig: tlsCfg,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
||||
@@ -23,7 +25,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||
)
|
||||
|
||||
func setupTestServer(t *testing.T) (*Server, *seal.Manager, *http.ServeMux) {
|
||||
func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
@@ -61,9 +63,9 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, *http.ServeMux) {
|
||||
logger := slog.Default()
|
||||
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.registerRoutes(mux)
|
||||
return srv, sealMgr, mux
|
||||
r := chi.NewRouter()
|
||||
srv.registerRoutes(r)
|
||||
return srv, sealMgr, r
|
||||
}
|
||||
|
||||
func TestStatusEndpoint(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user