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 @@
Name Type Path
| Common Name | {{.RootCN}} |
|---|---|
| Organization | {{.RootOrg}} |
| Valid From | {{.RootNotBefore}} |
| Valid Until | ++ {{.RootNotAfter}} + {{if .RootExpired}} Expired{{end}} + | +
No root CA configured.
+{{end}} + +{{if .IsAdmin}} +{{if or (not .HasRoot) .RootExpired}} +{{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}} + +| Name | Actions |
|---|---|
| {{.}} | +Download Cert (PEM) | +
No issuers configured.
+{{end}} + +{{if .IsAdmin}} +{{if .HasRoot}} +