From 658d067d788e111848c52b7e91bf9e8543fe72b1 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 14 Mar 2026 23:29:51 -0700 Subject: [PATCH] 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 --- .claude/settings.local.json | 5 +- CLAUDE.md | 8 + Makefile | 11 +- go.mod | 1 + go.sum | 2 + internal/engine/ca/ca.go | 144 ++++++-- internal/engine/ca/ca_test.go | 155 +++++++++ internal/server/routes.go | 589 +++++++++++++++++++++++--------- internal/server/server.go | 11 +- internal/server/server_test.go | 10 +- proto/metacrypt/v1/engine.proto | 1 + proto/metacrypt/v1/pki.proto | 36 ++ web/static/style.css | 7 + web/templates/dashboard.html | 33 +- web/templates/pki.html | 111 ++++++ 15 files changed, 923 insertions(+), 201 deletions(-) create mode 100644 proto/metacrypt/v1/pki.proto create mode 100644 web/templates/pki.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ded820a..8f41a30 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(git:*)" + "Bash(git:*)", + "Bash(go build:*)", + "Bash(go test:*)", + "Bash(go vet:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index ad6884d..3c32b0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,3 +49,11 @@ go vet ./... # Static analysis ├── Makefile └── metacrypt.toml.example ``` + +## Ignored Directories + +- `srv/` — Local runtime data (database, certs, config). Do not read, modify, or reference these files. + +## API Sync Rule + +The gRPC proto definitions (`proto/metacrypt/v1/`) and the REST API (`internal/server/routes.go`) must always be kept in sync. When adding, removing, or changing an endpoint in either surface, the other must be updated in the same change. Every REST endpoint must have a corresponding gRPC RPC (and vice versa), with matching request/response fields. diff --git a/Makefile b/Makefile index f39e4ff..50afc4c 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: build test vet clean docker all - -build: - go build ./... +.PHONY: build test vet clean docker all devserver metacrypt: go build -trimpath -ldflags="-s -w" -o metacrypt ./cmd/metacrypt +build: + go build ./... + test: go test ./... @@ -21,4 +21,7 @@ docker: docker-compose: docker compose -f deploy/docker/docker-compose.yml up --build +devserver: metacrypt + ./metacrypt server --config srv/metacrypt.toml + all: vet test metacrypt diff --git a/go.mod b/go.mod index b248bc9..d7cccb1 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils require ( git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000 git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000 + github.com/go-chi/chi/v5 v5.2.5 github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 5d6324d..510834e 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index 7863f64..b2a3e95 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -87,35 +87,62 @@ func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath return fmt.Errorf("ca: store config: %w", err) } - // Generate self-signed root CA. - creq := &certgen.CertificateRequest{ - KeySpec: certgen.KeySpec{ - Algorithm: cfg.KeyAlgorithm, - Size: cfg.KeySize, - }, - Subject: certgen.Subject{ - CommonName: cfg.Organization + " Root CA", - Organization: cfg.Organization, - Country: cfg.Country, - }, - Profile: certgen.Profile{ - IsCA: true, - PathLen: 1, - KeyUse: []string{"cert sign", "crl sign"}, - Expiry: cfg.RootExpiry, - }, - } + var rootCert *x509.Certificate + var rootKey crypto.PrivateKey + var certPEM, keyPEM []byte - rootCert, rootKey, err := certgen.GenerateSelfSigned(creq) - if err != nil { - return fmt.Errorf("ca: generate root CA: %w", err) - } + // If root_cert_pem and root_key_pem are provided, import them + // instead of generating a new root CA. + rootCertStr, _ := config["root_cert_pem"].(string) + rootKeyStr, _ := config["root_key_pem"].(string) - // Store root cert and key in barrier. - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw}) - keyPEM, err := marshalPrivateKey(rootKey) - if err != nil { - return fmt.Errorf("ca: marshal root key: %w", err) + if rootCertStr != "" && rootKeyStr != "" { + certPEM = []byte(rootCertStr) + keyPEM = []byte(rootKeyStr) + + var err error + rootCert, err = parseCertPEM(certPEM) + if err != nil { + return fmt.Errorf("ca: parse imported root cert: %w", err) + } + if !rootCert.IsCA { + return fmt.Errorf("ca: imported certificate is not a CA") + } + rootKey, err = parsePrivateKeyPEM(keyPEM) + if err != nil { + return fmt.Errorf("ca: parse imported root key: %w", err) + } + } else { + // Generate self-signed root CA. + creq := &certgen.CertificateRequest{ + KeySpec: certgen.KeySpec{ + Algorithm: cfg.KeyAlgorithm, + Size: cfg.KeySize, + }, + Subject: certgen.Subject{ + CommonName: cfg.Organization + " Root CA", + Organization: cfg.Organization, + Country: cfg.Country, + }, + Profile: certgen.Profile{ + IsCA: true, + PathLen: 1, + KeyUse: []string{"cert sign", "crl sign"}, + Expiry: cfg.RootExpiry, + }, + } + + var err error + rootCert, rootKey, err = certgen.GenerateSelfSigned(creq) + if err != nil { + return fmt.Errorf("ca: generate root CA: %w", err) + } + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw}) + keyPEM, err = marshalPrivateKey(rootKey) + if err != nil { + return fmt.Errorf("ca: marshal root key: %w", err) + } } if err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil { @@ -273,6 +300,8 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng return e.handleListCerts(ctx, req) case "renew": return e.handleRenew(ctx, req) + case "import-root": + return e.handleImportRoot(ctx, req) default: return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation) } @@ -327,6 +356,67 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) { // --- Operation handlers --- +func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + certStr, _ := req.Data["cert_pem"].(string) + keyStr, _ := req.Data["key_pem"].(string) + if certStr == "" || keyStr == "" { + return nil, fmt.Errorf("ca: cert_pem and key_pem are required") + } + + newCert, err := parseCertPEM([]byte(certStr)) + if err != nil { + return nil, fmt.Errorf("ca: parse imported cert: %w", err) + } + if !newCert.IsCA { + return nil, fmt.Errorf("ca: imported certificate is not a CA") + } + + newKey, err := parsePrivateKeyPEM([]byte(keyStr)) + if err != nil { + return nil, fmt.Errorf("ca: parse imported key: %w", err) + } + + e.mu.Lock() + defer e.mu.Unlock() + + // Only allow import if there is no root or the current root is expired. + if e.rootCert != nil && time.Now().Before(e.rootCert.NotAfter) { + return nil, fmt.Errorf("ca: current root is still valid (expires %s); cannot replace", e.rootCert.NotAfter.Format(time.RFC3339)) + } + + // Zeroize old key if present. + if e.rootKey != nil { + zeroizeKey(e.rootKey) + } + + // Store in barrier. + certPEM := []byte(certStr) + keyPEM := []byte(keyStr) + if err := e.barrier.Put(ctx, e.mountPath+"root/cert.pem", certPEM); err != nil { + return nil, fmt.Errorf("ca: store root cert: %w", err) + } + if err := e.barrier.Put(ctx, e.mountPath+"root/key.pem", keyPEM); err != nil { + return nil, fmt.Errorf("ca: store root key: %w", err) + } + + e.rootCert = newCert + e.rootKey = newKey + + return &engine.Response{ + Data: map[string]interface{}{ + "cn": newCert.Subject.CommonName, + "expires_at": newCert.NotAfter, + }, + }, nil +} + func (e *CAEngine) handleGetRoot(_ context.Context) (*engine.Response, error) { e.mu.RLock() defer e.mu.RUnlock() diff --git a/internal/engine/ca/ca_test.go b/internal/engine/ca/ca_test.go index fc4c7ca..63f099e 100644 --- a/internal/engine/ca/ca_test.go +++ b/internal/engine/ca/ca_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "testing" + "time" "git.wntrmute.dev/kyle/metacrypt/internal/barrier" "git.wntrmute.dev/kyle/metacrypt/internal/engine" @@ -113,6 +114,69 @@ func TestInitializeGeneratesRootCA(t *testing.T) { } } +func TestInitializeWithImportedRoot(t *testing.T) { + // First, generate a root CA to use as the import source. + srcEng, _ := setupEngine(t) + rootPEM, err := srcEng.GetRootCertPEM() + if err != nil { + t.Fatalf("GetRootCertPEM: %v", err) + } + // Get the key PEM from barrier. + srcKeyPEM, err := srcEng.barrier.Get(context.Background(), srcEng.mountPath+"root/key.pem") + if err != nil { + t.Fatalf("get root key: %v", err) + } + + // Now initialize a new engine with the imported root. + b := newMemBarrier() + eng := NewCAEngine().(*CAEngine) + ctx := context.Background() + + config := map[string]interface{}{ + "organization": "ImportOrg", + "root_cert_pem": string(rootPEM), + "root_key_pem": string(srcKeyPEM), + } + + if err := eng.Initialize(ctx, b, "engine/ca/imported/", config); err != nil { + t.Fatalf("Initialize with import: %v", err) + } + + if eng.rootCert == nil { + t.Fatal("root cert is nil after import") + } + if !eng.rootCert.IsCA { + t.Error("imported root is not a CA") + } + // The CN should be from the original cert, not the new org. + if eng.rootCert.Subject.CommonName != "TestOrg Root CA" { + t.Errorf("imported root CN: got %q, want %q", eng.rootCert.Subject.CommonName, "TestOrg Root CA") + } + + // Verify we can create issuers and issue certs from the imported root. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer from imported root: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "imported.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("issue from imported root: %v", err) + } +} + func TestUnsealSealLifecycle(t *testing.T) { eng, b := setupEngine(t) mountPath := "engine/ca/test/" @@ -596,6 +660,97 @@ func TestDeleteIssuer(t *testing.T) { } } +func TestImportRootRejectsValidRoot(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + // Generate a new root to try importing. + other, _ := setupEngine(t) + otherPEM, _ := other.GetRootCertPEM() + otherKeyPEM, _ := other.barrier.Get(ctx, other.mountPath+"root/key.pem") + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "import-root", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "cert_pem": string(otherPEM), + "key_pem": string(otherKeyPEM), + }, + }) + if err == nil { + t.Fatal("expected error when importing over a valid root") + } + if !strings.Contains(err.Error(), "still valid") { + t.Errorf("expected 'still valid' error, got: %v", err) + } +} + +func TestImportRootReplacesExpiredRoot(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + // Simulate an expired root by setting NotAfter to the past. + eng.mu.Lock() + eng.rootCert.NotAfter = time.Now().Add(-1 * time.Hour) + eng.mu.Unlock() + + // Generate a new root to import. + other, _ := setupEngine(t) + newPEM, _ := other.GetRootCertPEM() + newKeyPEM, _ := other.barrier.Get(ctx, other.mountPath+"root/key.pem") + + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "import-root", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "cert_pem": string(newPEM), + "key_pem": string(newKeyPEM), + }, + }) + if err != nil { + t.Fatalf("import-root: %v", err) + } + if resp.Data["cn"] == nil { + t.Error("expected cn in response") + } + + // Verify the root was replaced. + rootPEM, err := eng.GetRootCertPEM() + if err != nil { + t.Fatalf("GetRootCertPEM: %v", err) + } + if string(rootPEM) != string(newPEM) { + t.Error("root cert was not replaced") + } + + // Verify we can still create issuers with the new root. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "new-issuer"}, + }) + if err != nil { + t.Fatalf("create-issuer after import: %v", err) + } +} + +func TestImportRootRequiresAdmin(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "import-root", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "cert_pem": "fake", + "key_pem": "fake", + }, + }) + if err != ErrForbidden { + t.Errorf("expected ErrForbidden, got: %v", err) + } +} + func TestPublicMethods(t *testing.T) { eng, _ := setupEngine(t) ctx := context.Background() diff --git a/internal/server/routes.go b/internal/server/routes.go index 84477c7..64a614f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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) { diff --git a/internal/server/server.go b/internal/server/server.go index fd10123..ebbc1f4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4ed6ff0..198f495 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) { diff --git a/proto/metacrypt/v1/engine.proto b/proto/metacrypt/v1/engine.proto index e309952..a8c4e4c 100644 --- a/proto/metacrypt/v1/engine.proto +++ b/proto/metacrypt/v1/engine.proto @@ -16,6 +16,7 @@ service EngineService { message MountRequest { string name = 1; string type = 2; + google.protobuf.Struct config = 3; } message MountResponse {} diff --git a/proto/metacrypt/v1/pki.proto b/proto/metacrypt/v1/pki.proto new file mode 100644 index 0000000..f7d983b --- /dev/null +++ b/proto/metacrypt/v1/pki.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +// PKIService provides unauthenticated access to public CA certificates. +// These endpoints only require the service to be unsealed. +service PKIService { + rpc GetRootCert(GetRootCertRequest) returns (GetRootCertResponse); + rpc GetChain(GetChainRequest) returns (GetChainResponse); + rpc GetIssuerCert(GetIssuerCertRequest) returns (GetIssuerCertResponse); +} + +message GetRootCertRequest { + string mount = 1; +} +message GetRootCertResponse { + bytes cert_pem = 1; +} + +message GetChainRequest { + string mount = 1; + string issuer = 2; +} +message GetChainResponse { + bytes chain_pem = 1; +} + +message GetIssuerCertRequest { + string mount = 1; + string issuer = 2; +} +message GetIssuerCertResponse { + bytes cert_pem = 1; +} diff --git a/web/static/style.css b/web/static/style.css index cbed19d..00a7809 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -22,3 +22,10 @@ th { font-weight: 600; background: #f9fafb; } .admin-actions { margin-top: 0.5rem; } .admin-actions button { background: #dc2626; } .admin-actions button:hover { background: #b91c1c; } +.badge-danger { background: #fee2e2; color: #991b1b; } +.form-group textarea, .pem-input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 0.875rem; resize: vertical; } +.form-group input[type="file"] { padding: 0.25rem 0; border: none; } +.form-row { display: flex; gap: 1rem; } +.form-row .form-group { flex: 1; } +details { margin: 0.75rem 0; } +details summary { cursor: pointer; color: #2563eb; font-weight: 600; margin-bottom: 0.5rem; } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 404cf34..3493234 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -14,7 +14,11 @@ NameTypePath {{range .Mounts}} - {{.Name}}{{.Type}}{{.MountPath}} + + {{if eq (printf "%s" .Type) "ca"}}{{.Name}}{{else}}{{.Name}}{{end}} + {{.Type}} + {{.MountPath}} + {{end}} @@ -23,6 +27,33 @@ {{end}} {{if .IsAdmin}} +

Mount CA Engine

+{{if .MountError}}
{{.MountError}}
{{end}} +
+
+
+ + +
+
+ + +
+
+
+ Import existing root CA (optional) +
+ + +
+
+ + +
+
+ +
+

Admin Actions

diff --git a/web/templates/pki.html b/web/templates/pki.html new file mode 100644 index 0000000..682b258 --- /dev/null +++ b/web/templates/pki.html @@ -0,0 +1,111 @@ +{{define "title"}} - PKI: {{.MountName}}{{end}} +{{define "content"}} +

PKI Engine: {{.MountName}}

+ +

← Dashboard

+ +{{if .Error}} +
{{.Error}}
+{{end}} + +

Root CA

+{{if .HasRoot}} + + + + + + + + +
Common Name{{.RootCN}}
Organization{{.RootOrg}}
Valid From{{.RootNotBefore}}
Valid Until + {{.RootNotAfter}} + {{if .RootExpired}} Expired{{end}} +
+

+ Download Root CA (PEM) +

+{{else}} +

No root CA configured.

+{{end}} + +{{if .IsAdmin}} +{{if or (not .HasRoot) .RootExpired}} +

Import Root CA

+

{{if .RootExpired}}The current root CA has expired. Import a new one.{{else}}No root CA is present. Import one to get started.{{end}}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+{{end}} +{{end}} + +

Issuers

+{{if .Issuers}} + + + + {{range .Issuers}} + + + + + {{end}} + +
NameActions
{{.}}Download Cert (PEM)
+{{else}} +

No issuers configured.

+{{end}} + +{{if .IsAdmin}} +{{if .HasRoot}} +

Create Issuer

+{{if .IssuerError}}
{{.IssuerError}}
{{end}} +
+
+
+ + +
+
+ + +
+
+
+ Advanced options +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+{{end}} +{{end}} +{{end}}