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:
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git:*)"
|
"Bash(git:*)",
|
||||||
|
"Bash(go build:*)",
|
||||||
|
"Bash(go test:*)",
|
||||||
|
"Bash(go vet:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,11 @@ go vet ./... # Static analysis
|
|||||||
├── Makefile
|
├── Makefile
|
||||||
└── metacrypt.toml.example
|
└── 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.
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -1,11 +1,11 @@
|
|||||||
.PHONY: build test vet clean docker all
|
.PHONY: build test vet clean docker all devserver
|
||||||
|
|
||||||
build:
|
|
||||||
go build ./...
|
|
||||||
|
|
||||||
metacrypt:
|
metacrypt:
|
||||||
go build -trimpath -ldflags="-s -w" -o metacrypt ./cmd/metacrypt
|
go build -trimpath -ldflags="-s -w" -o metacrypt ./cmd/metacrypt
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
@@ -21,4 +21,7 @@ docker:
|
|||||||
docker-compose:
|
docker-compose:
|
||||||
docker compose -f deploy/docker/docker-compose.yml up --build
|
docker compose -f deploy/docker/docker-compose.yml up --build
|
||||||
|
|
||||||
|
devserver: metacrypt
|
||||||
|
./metacrypt server --config srv/metacrypt.toml
|
||||||
|
|
||||||
all: vet test metacrypt
|
all: vet test metacrypt
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -9,6 +9,7 @@ replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils
|
|||||||
require (
|
require (
|
||||||
git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000
|
git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000
|
||||||
git.wntrmute.dev/kyle/mcias/clients/go 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/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
|||||||
@@ -87,6 +87,32 @@ func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath
|
|||||||
return fmt.Errorf("ca: store config: %w", err)
|
return fmt.Errorf("ca: store config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rootCert *x509.Certificate
|
||||||
|
var rootKey crypto.PrivateKey
|
||||||
|
var certPEM, keyPEM []byte
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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.
|
// Generate self-signed root CA.
|
||||||
creq := &certgen.CertificateRequest{
|
creq := &certgen.CertificateRequest{
|
||||||
KeySpec: certgen.KeySpec{
|
KeySpec: certgen.KeySpec{
|
||||||
@@ -106,17 +132,18 @@ func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
rootCert, rootKey, err := certgen.GenerateSelfSigned(creq)
|
var err error
|
||||||
|
rootCert, rootKey, err = certgen.GenerateSelfSigned(creq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ca: generate root CA: %w", err)
|
return fmt.Errorf("ca: generate root CA: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store root cert and key in barrier.
|
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw})
|
||||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw})
|
keyPEM, err = marshalPrivateKey(rootKey)
|
||||||
keyPEM, err := marshalPrivateKey(rootKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ca: marshal root key: %w", err)
|
return fmt.Errorf("ca: marshal root key: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil {
|
if err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil {
|
||||||
return fmt.Errorf("ca: store root cert: %w", err)
|
return fmt.Errorf("ca: store root cert: %w", err)
|
||||||
@@ -273,6 +300,8 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng
|
|||||||
return e.handleListCerts(ctx, req)
|
return e.handleListCerts(ctx, req)
|
||||||
case "renew":
|
case "renew":
|
||||||
return e.handleRenew(ctx, req)
|
return e.handleRenew(ctx, req)
|
||||||
|
case "import-root":
|
||||||
|
return e.handleImportRoot(ctx, req)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation)
|
return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation)
|
||||||
}
|
}
|
||||||
@@ -327,6 +356,67 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) {
|
|||||||
|
|
||||||
// --- Operation handlers ---
|
// --- 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) {
|
func (e *CAEngine) handleGetRoot(_ context.Context) (*engine.Response, error) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"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) {
|
func TestUnsealSealLifecycle(t *testing.T) {
|
||||||
eng, b := setupEngine(t)
|
eng, b := setupEngine(t)
|
||||||
mountPath := "engine/ca/test/"
|
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) {
|
func TestPublicMethods(t *testing.T) {
|
||||||
eng, _ := setupEngine(t)
|
eng, _ := setupEngine(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
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/crypto"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
||||||
@@ -19,58 +26,58 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
func (s *Server) registerRoutes(r chi.Router) {
|
||||||
// Static files.
|
// 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.
|
// Web UI routes.
|
||||||
mux.HandleFunc("/", s.handleWebRoot)
|
r.Get("/", s.handleWebRoot)
|
||||||
mux.HandleFunc("/init", s.handleWebInit)
|
r.HandleFunc("/init", s.handleWebInit)
|
||||||
mux.HandleFunc("/unseal", s.handleWebUnseal)
|
r.HandleFunc("/unseal", s.handleWebUnseal)
|
||||||
mux.HandleFunc("/login", s.handleWebLogin)
|
r.HandleFunc("/login", s.handleWebLogin)
|
||||||
mux.HandleFunc("/dashboard", s.requireAuthWeb(s.handleWebDashboard))
|
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.
|
// API routes.
|
||||||
mux.HandleFunc("/v1/status", s.handleStatus)
|
r.Get("/v1/status", s.handleStatus)
|
||||||
mux.HandleFunc("/v1/init", s.handleInit)
|
r.Post("/v1/init", s.handleInit)
|
||||||
mux.HandleFunc("/v1/unseal", s.handleUnseal)
|
r.Post("/v1/unseal", s.handleUnseal)
|
||||||
mux.HandleFunc("/v1/seal", s.requireAdmin(s.handleSeal))
|
r.Post("/v1/seal", s.requireAdmin(s.handleSeal))
|
||||||
|
|
||||||
mux.HandleFunc("/v1/auth/login", s.handleLogin)
|
r.Post("/v1/auth/login", s.handleLogin)
|
||||||
mux.HandleFunc("/v1/auth/logout", s.requireAuth(s.handleLogout))
|
r.Post("/v1/auth/logout", s.requireAuth(s.handleLogout))
|
||||||
mux.HandleFunc("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
|
r.Get("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
|
||||||
|
|
||||||
mux.HandleFunc("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
|
r.Get("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
|
||||||
mux.HandleFunc("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
|
r.Post("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
|
||||||
mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
r.Post("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
||||||
mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
r.Post("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
||||||
|
|
||||||
// Public PKI routes (no auth required, but must be unsealed).
|
// Public PKI routes (no auth required, but must be unsealed).
|
||||||
mux.HandleFunc("GET /v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
||||||
mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
r.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}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
|
||||||
|
|
||||||
mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
||||||
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API Handlers ---
|
// --- API Handlers ---
|
||||||
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"state": s.seal.State().String(),
|
"state": s.seal.State().String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var req struct {
|
||||||
Password string `json:"password"`
|
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) {
|
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 {
|
var req struct {
|
||||||
Password string `json:"password"`
|
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) {
|
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 {
|
if err := s.engines.SealAll(); err != nil {
|
||||||
s.logger.Error("seal engines failed", "error", err)
|
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) {
|
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 {
|
if s.seal.State() != seal.StateUnsealed {
|
||||||
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
||||||
return
|
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) {
|
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)
|
token := extractToken(r)
|
||||||
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
|
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
|
||||||
CACertPath: s.cfg.MCIAS.CACert,
|
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) {
|
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())
|
info := TokenInfoFromContext(r.Context())
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"username": info.Username,
|
"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) {
|
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()
|
mounts := s.engines.ListMounts()
|
||||||
writeJSON(w, http.StatusOK, mounts)
|
writeJSON(w, http.StatusOK, mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
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) {
|
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 {
|
var req struct {
|
||||||
Name string `json:"name"`
|
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) {
|
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 {
|
var req struct {
|
||||||
Mount string `json:"mount"`
|
Mount string `json:"mount"`
|
||||||
Operation string `json:"operation"`
|
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)
|
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
// Map known errors to appropriate status codes.
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, engine.ErrMountNotFound):
|
case errors.Is(err, engine.ErrMountNotFound):
|
||||||
status = http.StatusNotFound
|
status = http.StatusNotFound
|
||||||
@@ -342,93 +310,6 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, resp.Data)
|
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) {
|
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
||||||
info := TokenInfoFromContext(r.Context())
|
info := TokenInfoFromContext(r.Context())
|
||||||
switch r.Method {
|
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 ---
|
// --- Web Handlers ---
|
||||||
|
|
||||||
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state := s.seal.State()
|
state := s.seal.State()
|
||||||
switch state {
|
switch state {
|
||||||
case seal.StateUninitialized:
|
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.
|
// requireAuthWeb redirects to login for web pages instead of returning 401.
|
||||||
func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"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.
|
// Start starts the HTTPS server.
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
mux := http.NewServeMux()
|
r := chi.NewRouter()
|
||||||
s.registerRoutes(mux)
|
r.Use(s.loggingMiddleware)
|
||||||
|
s.registerRoutes(r)
|
||||||
|
|
||||||
tlsCfg := &tls.Config{
|
tlsCfg := &tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
CipherSuites: []uint16{
|
CipherSuites: []uint16{
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
tls.TLS_ECDHE_RSA_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{
|
s.httpSrv = &http.Server{
|
||||||
Addr: s.cfg.Server.ListenAddr,
|
Addr: s.cfg.Server.ListenAddr,
|
||||||
Handler: s.loggingMiddleware(mux),
|
Handler: r,
|
||||||
TLSConfig: tlsCfg,
|
TLSConfig: tlsCfg,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
||||||
@@ -23,7 +25,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
"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()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
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()
|
logger := slog.Default()
|
||||||
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
r := chi.NewRouter()
|
||||||
srv.registerRoutes(mux)
|
srv.registerRoutes(r)
|
||||||
return srv, sealMgr, mux
|
return srv, sealMgr, r
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusEndpoint(t *testing.T) {
|
func TestStatusEndpoint(t *testing.T) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ service EngineService {
|
|||||||
message MountRequest {
|
message MountRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string type = 2;
|
string type = 2;
|
||||||
|
google.protobuf.Struct config = 3;
|
||||||
}
|
}
|
||||||
message MountResponse {}
|
message MountResponse {}
|
||||||
|
|
||||||
|
|||||||
36
proto/metacrypt/v1/pki.proto
Normal file
36
proto/metacrypt/v1/pki.proto
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,3 +22,10 @@ th { font-weight: 600; background: #f9fafb; }
|
|||||||
.admin-actions { margin-top: 0.5rem; }
|
.admin-actions { margin-top: 0.5rem; }
|
||||||
.admin-actions button { background: #dc2626; }
|
.admin-actions button { background: #dc2626; }
|
||||||
.admin-actions button:hover { background: #b91c1c; }
|
.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; }
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
<thead><tr><th>Name</th><th>Type</th><th>Path</th></tr></thead>
|
<thead><tr><th>Name</th><th>Type</th><th>Path</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Mounts}}
|
{{range .Mounts}}
|
||||||
<tr><td>{{.Name}}</td><td>{{.Type}}</td><td>{{.MountPath}}</td></tr>
|
<tr>
|
||||||
|
<td>{{if eq (printf "%s" .Type) "ca"}}<a href="/pki">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td>
|
||||||
|
<td>{{.Type}}</td>
|
||||||
|
<td>{{.MountPath}}</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -23,6 +27,33 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
|
<h3>Mount CA Engine</h3>
|
||||||
|
{{if .MountError}}<div class="error">{{.MountError}}</div>{{end}}
|
||||||
|
<form method="post" action="/dashboard/mount-ca" enctype="multipart/form-data">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mount_name">Mount Name</label>
|
||||||
|
<input type="text" id="mount_name" name="name" placeholder="pki" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="organization">Organization</label>
|
||||||
|
<input type="text" id="organization" name="organization" placeholder="Metacircular">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>Import existing root CA (optional)</summary>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cert_file">Certificate PEM file</label>
|
||||||
|
<input type="file" id="cert_file" name="cert_file" accept=".pem,.crt">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key_file">Private Key PEM file</label>
|
||||||
|
<input type="file" id="key_file" name="key_file" accept=".pem,.key">
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="submit">Mount</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<h3>Admin Actions</h3>
|
<h3>Admin Actions</h3>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
<button hx-post="/v1/seal" hx-confirm="Are you sure you want to seal the service?">Seal Service</button>
|
<button hx-post="/v1/seal" hx-confirm="Are you sure you want to seal the service?">Seal Service</button>
|
||||||
|
|||||||
111
web/templates/pki.html
Normal file
111
web/templates/pki.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{{define "title"}} - PKI: {{.MountName}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h2>PKI Engine: {{.MountName}}</h2>
|
||||||
|
|
||||||
|
<p><a href="/dashboard">← Dashboard</a></p>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h3>Root CA</h3>
|
||||||
|
{{if .HasRoot}}
|
||||||
|
<table>
|
||||||
|
<tr><th>Common Name</th><td>{{.RootCN}}</td></tr>
|
||||||
|
<tr><th>Organization</th><td>{{.RootOrg}}</td></tr>
|
||||||
|
<tr><th>Valid From</th><td>{{.RootNotBefore}}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>Valid Until</th>
|
||||||
|
<td>
|
||||||
|
{{.RootNotAfter}}
|
||||||
|
{{if .RootExpired}} <span class="badge badge-danger">Expired</span>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top: 0.5rem;">
|
||||||
|
<a href="/v1/pki/{{.MountName}}/ca" download="root-ca.pem">Download Root CA (PEM)</a>
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p>No root CA configured.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
{{if or (not .HasRoot) .RootExpired}}
|
||||||
|
<h3>Import Root CA</h3>
|
||||||
|
<p>{{if .RootExpired}}The current root CA has expired. Import a new one.{{else}}No root CA is present. Import one to get started.{{end}}</p>
|
||||||
|
<form method="post" action="/pki/import-root" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cert_file">Certificate PEM</label>
|
||||||
|
<input type="file" id="cert_file" name="cert_file" accept=".pem,.crt">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cert_pem">Or paste certificate PEM</label>
|
||||||
|
<textarea id="cert_pem" name="cert_pem" rows="6" class="pem-input" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key_file">Private Key PEM</label>
|
||||||
|
<input type="file" id="key_file" name="key_file" accept=".pem,.key">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key_pem">Or paste private key PEM</label>
|
||||||
|
<textarea id="key_pem" name="key_pem" rows="6" class="pem-input" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Import Root CA</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h3>Issuers</h3>
|
||||||
|
{{if .Issuers}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Issuers}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.}}</td>
|
||||||
|
<td><a href="/pki/{{.}}" download="{{.}}.pem">Download Cert (PEM)</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No issuers configured.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
{{if .HasRoot}}
|
||||||
|
<h3>Create Issuer</h3>
|
||||||
|
{{if .IssuerError}}<div class="error">{{.IssuerError}}</div>{{end}}
|
||||||
|
<form method="post" action="/pki/create-issuer">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer_name">Issuer Name</label>
|
||||||
|
<input type="text" id="issuer_name" name="name" placeholder="default" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer_expiry">Expiry</label>
|
||||||
|
<input type="text" id="issuer_expiry" name="expiry" placeholder="43800h (5 years)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>Advanced options</summary>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer_key_alg">Key Algorithm</label>
|
||||||
|
<input type="text" id="issuer_key_alg" name="key_algorithm" placeholder="ECDSA (default)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer_key_size">Key Size</label>
|
||||||
|
<input type="text" id="issuer_key_size" name="key_size" placeholder="256 (default)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer_max_ttl">Max Leaf TTL</label>
|
||||||
|
<input type="text" id="issuer_max_ttl" name="max_ttl" placeholder="2160h (90 days)">
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="submit">Create Issuer</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user