Checkpoint: grpc auth fix, issuer list/detail, v2 protos, architecture docs

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-03-15 11:39:13 -07:00
parent d0b1875dbb
commit ad167aed9b
41 changed files with 1080 additions and 219 deletions

View File

@@ -1 +1 @@
[{"lang":"en","usageCount":6}]
[{"lang":"en","usageCount":20}]

View File

@@ -71,9 +71,15 @@ internal/
policy/ Priority-based ACL engine
engine/ Pluggable engine registry & interface
ca/ CA (PKI) engine — X.509 certificate issuance
server/ HTTP server, routes, middleware
server/ REST API HTTP server, routes, middleware
grpcserver/ gRPC server, interceptors, per-service handlers
webserver/ Web UI HTTP server, routes, HTMX handlers
proto/metacrypt/
v1/ Original gRPC proto definitions (generic Execute RPC)
v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields)
gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1)
web/
templates/ Go HTML templates (layout, init, unseal, login, dashboard)
templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI)
static/ CSS, HTMX
deploy/ Docker Compose, example configs
```
@@ -432,6 +438,9 @@ Passed as `config` at mount time:
## API Surface
Metacrypt exposes two API surfaces: a JSON REST API and a gRPC API. Both are
kept in sync — every operation available via REST has a corresponding gRPC RPC.
### Seal/Unseal (Unauthenticated)
| Method | Path | Description | Precondition |
@@ -503,6 +512,70 @@ HTTP status codes:
- `412` — service not initialized
- `503` — service is sealed
### gRPC API
Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API
versions exist:
#### v1 (current implementation)
The v1 API uses a generic `Execute` RPC for all engine operations:
```protobuf
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
message ExecuteRequest {
string mount = 1;
string operation = 2;
string path = 3;
google.protobuf.Struct data = 4; // JSON-like map
}
message ExecuteResponse {
google.protobuf.Struct data = 1; // JSON-like map
}
```
Timestamps are represented as RFC3339 strings within the `Struct` payload.
The `EngineService` also provides `Mount`, `Unmount`, and `ListMounts` RPCs
for engine lifecycle management.
#### v2 (defined, not yet implemented)
The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with
strongly-typed, per-operation RPCs and uses `google.protobuf.Timestamp` for
all time fields. Key changes:
- **`CAService`**: 11 typed RPCs — `ImportRoot`, `GetRoot`, `CreateIssuer`,
`DeleteIssuer`, `ListIssuers`, `GetIssuer`, `GetChain`, `IssueCert`,
`GetCert`, `ListCerts`, `RenewCert`.
- **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the
generic `Execute` RPC. `MountRequest.config` is `map<string, string>`
instead of `google.protobuf.Struct`.
- **Timestamps**: All `issued_at` / `expires_at` fields use
`google.protobuf.Timestamp` instead of RFC3339 strings.
- **Message types**: `CertRecord` (full certificate data) and `CertSummary`
(lightweight, for list responses) replace the generic struct maps.
- **`ACMEService`** and **`AuthService`**: String timestamps replaced by
`google.protobuf.Timestamp`.
The v2 proto definitions pass `buf lint` with no warnings. Server-side
implementation of v2 is planned as a future milestone.
#### gRPC Interceptors (v1)
The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate
access:
| Interceptor map | Effect |
|-----------------------|-----------------------------------------------------|
| `sealRequiredMethods` | Returns `UNAVAILABLE` if the barrier is sealed |
| `authRequiredMethods` | Validates MCIAS bearer token; populates caller info |
| `adminRequiredMethods`| Requires `IsAdmin == true` on the caller |
All three maps include the `Execute` RPC, ensuring engine operations are
always authenticated and gated on unseal state.
---
## Web Interface
@@ -510,17 +583,25 @@ HTTP status codes:
Metacrypt includes an HTMX-powered web UI for basic operations:
| Route | Purpose |
|---------------|--------------------------------------------------|
|--------------------------|-------------------------------------------------------|
| `/` | Redirects based on service state |
| `/init` | Password setup form (first-time only) |
| `/unseal` | Password entry to unseal |
| `/login` | MCIAS login form (username, password, TOTP) |
| `/dashboard` | Engine mounts, service state, admin controls |
| `/pki` | PKI overview: list issuers, download CA/issuer PEMs |
| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer |
The dashboard shows mounted engines, the service state, and (for admins) a seal
button. Templates use Go's `html/template` with a shared layout. HTMX provides
form submission without full page reloads.
The PKI pages communicate with the backend via the internal gRPC client
(`internal/webserver/client.go`), which wraps the v1 gRPC `Execute` RPC.
The issuer detail page supports filtering certificates by common name
(case-insensitive substring match) and sorting by common name (default) or
expiry date.
---
## Database Schema
@@ -683,7 +764,9 @@ closing connections before exit.
### Planned Capabilities
- **gRPC API** — In addition to the JSON REST API (config field already reserved)
- **gRPC v2 server implementation** — The v2 typed proto definitions are
complete; the server-side handlers and generated Go code remain to be
implemented
- **Post-quantum readiness** — Hybrid key exchange (ML-KEM + ECDH); the
versioned ciphertext format and engine interface are designed for algorithm
agility

View File

@@ -1,36 +1,8 @@
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt-web ./cmd/metacrypt-web
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S metacrypt \
&& adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \
&& mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt
COPY --from=builder /metacrypt /usr/local/bin/metacrypt
COPY --from=builder /metacrypt-web /usr/local/bin/metacrypt-web
# /srv/metacrypt is the single volume mount point.
# It must contain:
# metacrypt.toml — configuration file
# certs/ — TLS certificate and key
# metacrypt.db — created automatically on first run
VOLUME /srv/metacrypt
WORKDIR /srv/metacrypt
EXPOSE 8443
USER metacrypt
ENTRYPOINT ["metacrypt"]
CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
# This file is retained for reference only.
# The API server and web UI are built as separate images:
#
# Dockerfile.api — metacrypt API server (ports 8443, 9443)
# Dockerfile.web — metacrypt web UI (port 8080)
#
# Use `make docker` or `docker compose` (deploy/docker/docker-compose.yml)
# to build both images.

35
Dockerfile.api Normal file
View File

@@ -0,0 +1,35 @@
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S metacrypt \
&& adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \
&& mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt
COPY --from=builder /metacrypt /usr/local/bin/metacrypt
# /srv/metacrypt is the single volume mount point.
# It must contain:
# metacrypt.toml — configuration file
# certs/ — TLS certificate and key
# metacrypt.db — created automatically on first run
VOLUME /srv/metacrypt
WORKDIR /srv/metacrypt
EXPOSE 8443
EXPOSE 9443
USER metacrypt
ENTRYPOINT ["metacrypt"]
CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"]

33
Dockerfile.web Normal file
View File

@@ -0,0 +1,33 @@
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt-web ./cmd/metacrypt-web
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S metacrypt \
&& adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \
&& mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt
COPY --from=builder /metacrypt-web /usr/local/bin/metacrypt-web
# /srv/metacrypt is the single volume mount point.
# It must contain:
# metacrypt.toml — configuration file
# certs/ — TLS certificate and key
VOLUME /srv/metacrypt
WORKDIR /srv/metacrypt
EXPOSE 8080
USER metacrypt
ENTRYPOINT ["metacrypt-web"]
CMD ["--config", "/srv/metacrypt/metacrypt.toml"]

View File

@@ -1,7 +1,9 @@
.PHONY: build test vet lint proto-lint clean docker all devserver metacrypt metacrypt-web proto
.PHONY: build test vet lint proto-lint clean docker all devserver metacrypt metacrypt-web proto binaries
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)"
binaries: metacrypt metacrypt-web
proto:
protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/metacrypt \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/metacrypt \
@@ -33,7 +35,8 @@ clean:
rm -f metacrypt metacrypt-web
docker:
docker build -t metacrypt .
docker build -t metacrypt -f Dockerfile.api .
docker build -t metacrypt-web -f Dockerfile.web .
docker-compose:
docker compose -f deploy/docker/docker-compose.yml up --build

View File

@@ -31,7 +31,7 @@ func init() {
}
func run(cmd *cobra.Command, args []string) error {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
configPath := cfgFile
if configPath == "" {

View File

@@ -95,18 +95,8 @@ func runServer(cmd *cobra.Command, args []string) error {
}
}()
if cfg.Server.GRPCAddr != "" {
go func() {
if err := srv.StartGRPC(); err != nil {
logger.Error("grpc server error", "error", err)
os.Exit(1)
}
}()
}
<-ctx.Done()
logger.Info("shutting down")
grpcSrv.Shutdown()
srv.ShutdownGRPC()
return srv.Shutdown(context.Background())
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
@@ -49,7 +50,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
Transport: &http.Transport{TLSClientConfig: tlsCfg},
}
resp, err := client.Get(statusAddr + "/v1/status")
resp, err := client.Get(strings.TrimRight(statusAddr, "/") + "/v1/status")
if err != nil {
return err
}

View File

@@ -2,7 +2,7 @@ services:
metacrypt:
build:
context: ../..
dockerfile: Dockerfile
dockerfile: Dockerfile.api
container_name: metacrypt
restart: unless-stopped
ports:
@@ -20,9 +20,8 @@ services:
metacrypt-web:
build:
context: ../..
dockerfile: Dockerfile
dockerfile: Dockerfile.web
container_name: metacrypt-web
command: ["/usr/local/bin/metacrypt-web", "--config", "/srv/metacrypt/metacrypt.toml"]
restart: unless-stopped
ports:
- "8080:8080"

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/acme.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/acme.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/auth.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/auth.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/common.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/engine.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/engine.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/pki.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/pki.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/policy.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/policy.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.33.4
// source: proto/metacrypt/v1/system.proto
package metacryptv1

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.33.4
// source: proto/metacrypt/v1/system.proto
package metacryptv1

View File

@@ -412,7 +412,7 @@ func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*
return &engine.Response{
Data: map[string]interface{}{
"cn": newCert.Subject.CommonName,
"expires_at": newCert.NotAfter,
"expires_at": newCert.NotAfter.Format(time.RFC3339),
},
}, nil
}
@@ -639,7 +639,7 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e
return nil, ErrSealed
}
names := make([]string, 0, len(e.issuers))
names := make([]interface{}, 0, len(e.issuers))
for name := range e.issuers {
names = append(names, name)
}
@@ -795,7 +795,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
"cn": cn,
"sans": allSANs,
"issued_by": req.CallerInfo.Username,
"expires_at": leafCert.NotAfter,
"expires_at": leafCert.NotAfter.Format(time.RFC3339),
},
}, nil
}
@@ -838,8 +838,8 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
"profile": record.Profile,
"cert_pem": record.CertPEM,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
"issued_at": record.IssuedAt.Format(time.RFC3339),
"expires_at": record.ExpiresAt.Format(time.RFC3339),
},
}, nil
}
@@ -857,7 +857,7 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
return nil, fmt.Errorf("ca: list certs: %w", err)
}
var certs []map[string]interface{}
var certs []interface{}
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
@@ -876,8 +876,8 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
"cn": record.CN,
"profile": record.Profile,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
"issued_at": record.IssuedAt.Format(time.RFC3339),
"expires_at": record.ExpiresAt.Format(time.RFC3339),
})
}
@@ -1009,7 +1009,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
"key_pem": string(newKeyPEM),
"chain_pem": string(chainPEM),
"cn": record.CN,
"expires_at": newCert.NotAfter,
"expires_at": newCert.NotAfter.Format(time.RFC3339),
},
}, nil
}

View File

@@ -566,7 +566,7 @@ func TestGetAndListCerts(t *testing.T) {
t.Fatalf("list-certs: %v", err)
}
certs, ok := listResp.Data["certs"].([]map[string]interface{})
certs, ok := listResp.Data["certs"].([]interface{})
if !ok {
t.Fatalf("certs type: %T", listResp.Data["certs"])
}
@@ -575,7 +575,7 @@ func TestGetAndListCerts(t *testing.T) {
}
// Get a specific cert.
serial := certs[0]["serial"].(string) //nolint:errcheck
serial := certs[0].(map[string]interface{})["serial"].(string) //nolint:errcheck
getResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "get-cert",
CallerInfo: userCaller(),

View File

@@ -90,6 +90,12 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p
}
}
username := ""
if ti != nil {
username = ti.Username
}
es.s.logger.Info("grpc: engine execute", "mount", req.Mount, "operation", req.Operation, "username", username)
resp, err := es.s.engines.HandleRequest(ctx, req.Mount, engReq)
if err != nil {
st := codes.Internal
@@ -101,8 +107,10 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p
case strings.Contains(err.Error(), "not found"):
st = codes.NotFound
}
es.s.logger.Error("grpc: engine execute failed", "mount", req.Mount, "operation", req.Operation, "username", username, "error", err)
return nil, status.Error(st, err.Error())
}
es.s.logger.Info("grpc: engine execute ok", "mount", req.Mount, "operation", req.Operation, "username", username)
pbData, err := structpb.NewStruct(resp.Data)
if err != nil {

View File

@@ -111,7 +111,7 @@ func sealRequiredMethods() map[string]bool {
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.EngineService/Execute": true,
"/metacrypt.v1.PKIService/GetRootCert": true,
"/metacrypt.v1.PKIService/GetChain": true,
"/metacrypt.v1.PKIService/GetIssuerCert": true,
@@ -134,7 +134,7 @@ func authRequiredMethods() map[string]bool {
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.EngineService/Execute": true,
"/metacrypt.v1.PolicyService/CreatePolicy": true,
"/metacrypt.v1.PolicyService/ListPolicies": true,
"/metacrypt.v1.PolicyService/GetPolicy": true,

View File

@@ -1,126 +0,0 @@
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
grpcstatus "google.golang.org/grpc/status"
metacryptv1 "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
// systemServiceServer implements metacryptv1.SystemServiceServer.
type systemServiceServer struct {
metacryptv1.UnimplementedSystemServiceServer
s *Server
}
func (g *systemServiceServer) Status(_ context.Context, _ *metacryptv1.StatusRequest) (*metacryptv1.StatusResponse, error) {
return &metacryptv1.StatusResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Init(ctx context.Context, req *metacryptv1.InitRequest) (*metacryptv1.InitResponse, error) {
params := crypto.Argon2Params{
Time: g.s.cfg.Seal.Argon2Time,
Memory: g.s.cfg.Seal.Argon2Memory,
Threads: g.s.cfg.Seal.Argon2Threads,
}
if err := g.s.seal.Initialize(ctx, []byte(req.Password), params); err != nil {
if errors.Is(err, seal.ErrAlreadyInitialized) {
return nil, grpcstatus.Error(codes.AlreadyExists, "already initialized")
}
g.s.logger.Error("grpc init failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "initialization failed")
}
return &metacryptv1.InitResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Unseal(ctx context.Context, req *metacryptv1.UnsealRequest) (*metacryptv1.UnsealResponse, error) {
if err := g.s.seal.Unseal([]byte(req.Password)); err != nil {
switch {
case errors.Is(err, seal.ErrNotInitialized):
return nil, grpcstatus.Error(codes.FailedPrecondition, "not initialized")
case errors.Is(err, seal.ErrInvalidPassword):
return nil, grpcstatus.Error(codes.Unauthenticated, "invalid password")
case errors.Is(err, seal.ErrRateLimited):
return nil, grpcstatus.Error(codes.ResourceExhausted, "too many attempts, try again later")
case errors.Is(err, seal.ErrNotSealed):
return nil, grpcstatus.Error(codes.AlreadyExists, "already unsealed")
default:
g.s.logger.Error("grpc unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "unseal failed")
}
}
if err := g.s.engines.UnsealAll(ctx); err != nil {
g.s.logger.Error("grpc engine unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "engine unseal failed")
}
return &metacryptv1.UnsealResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Seal(_ context.Context, _ *metacryptv1.SealRequest) (*metacryptv1.SealResponse, error) {
if err := g.s.engines.SealAll(); err != nil {
g.s.logger.Error("grpc seal engines failed", "error", err)
}
if err := g.s.seal.Seal(); err != nil {
g.s.logger.Error("grpc seal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "seal failed")
}
g.s.auth.ClearCache()
return &metacryptv1.SealResponse{State: g.s.seal.State().String()}, nil
}
// StartGRPC starts the gRPC server on cfg.Server.GRPCAddr using the same TLS
// certificate as the HTTP server. It blocks until the listener closes.
func (s *Server) StartGRPC() error {
if s.cfg.Server.GRPCAddr == "" {
return nil
}
cert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("grpc: load TLS key pair: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
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,
},
}
grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
metacryptv1.RegisterSystemServiceServer(grpcSrv, &systemServiceServer{s: s})
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("grpc: listen: %w", err)
}
s.grpcSrv = grpcSrv
s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr)
if err := grpcSrv.Serve(lis); err != nil {
return fmt.Errorf("grpc: serve: %w", err)
}
return nil
}
// ShutdownGRPC gracefully stops the gRPC server.
func (s *Server) ShutdownGRPC() {
if s.grpcSrv != nil {
s.grpcSrv.GracefulStop()
}
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"google.golang.org/grpc"
@@ -24,9 +25,12 @@ type VaultClient struct {
}
// NewVaultClient dials the vault gRPC server and returns a client.
func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) {
logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath)
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
logger.Debug("loading vault CA certificate", "path", caCertPath)
pemData, err := os.ReadFile(caCertPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("webserver: read CA cert: %w", err)
@@ -36,12 +40,17 @@ func NewVaultClient(addr, caCertPath string) (*VaultClient, error) {
return nil, fmt.Errorf("webserver: parse CA cert")
}
tlsCfg.RootCAs = pool
logger.Debug("vault CA certificate loaded successfully")
} else {
logger.Debug("no CA cert configured, using system roots")
}
logger.Debug("dialing vault gRPC", "addr", addr)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("webserver: dial vault: %w", err)
}
logger.Debug("vault gRPC connection established", "addr", addr)
return &VaultClient{
conn: conn,

View File

@@ -12,6 +12,17 @@ import (
"google.golang.org/grpc/status"
)
// splitLines splits a newline-delimited string into non-empty trimmed lines.
func splitLines(s string) []interface{} {
var out []interface{}
for _, line := range strings.Split(s, "\n") {
if v := strings.TrimSpace(line); v != "" {
out = append(out, v)
}
}
return out
}
func (ws *WebServer) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS))))
@@ -26,6 +37,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
@@ -394,7 +407,159 @@ func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(certPEM) //nolint:gosec
}
func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
issuerName := chi.URLParam(r, "issuer")
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-certs", nil)
if err != nil {
http.Error(w, "failed to list certificates", http.StatusInternalServerError)
return
}
nameFilter := strings.ToLower(r.URL.Query().Get("name"))
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
sortBy = "cn"
}
var certs []map[string]interface{}
if raw, ok := resp["certs"]; ok {
if list, ok := raw.([]interface{}); ok {
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
issuer, _ := m["issuer"].(string)
if issuer != issuerName {
continue
}
if nameFilter != "" {
cn, _ := m["cn"].(string)
if !strings.Contains(strings.ToLower(cn), nameFilter) {
continue
}
}
certs = append(certs, m)
}
}
}
}
// Sort: by expiry date or by common name (default).
if sortBy == "expiry" {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["expires_at"].(string)
b, _ := certs[j]["expires_at"].(string)
if a > b {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
} else {
for i := 1; i < len(certs); i++ {
for j := i; j > 0; j-- {
a, _ := certs[j-1]["cn"].(string)
b, _ := certs[j]["cn"].(string)
if strings.ToLower(a) > strings.ToLower(b) {
certs[j-1], certs[j] = certs[j], certs[j-1]
}
}
}
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
ws.renderTemplate(w, "issuer_detail.html", data)
}
func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
commonName := r.FormValue("common_name")
if commonName == "" {
ws.renderPKIWithError(w, r, mountName, info, "Common name is required")
return
}
issuer := r.FormValue("issuer")
if issuer == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
return
}
reqData := map[string]interface{}{
"common_name": commonName,
"issuer": issuer,
}
if v := r.FormValue("profile"); v != "" {
reqData["profile"] = v
}
if v := r.FormValue("ttl"); v != "" {
reqData["ttl"] = v
}
if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 {
reqData["dns_names"] = lines
}
if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 {
reqData["ip_addresses"] = lines
}
resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "issue", reqData)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
// Re-render the PKI page with the issued certificate displayed.
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuedCert": resp,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
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
}
}
if issuerResp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = issuerResp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
@@ -412,6 +577,9 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request,
data["HasRoot"] = true
}
}
if resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil {
data["Issuers"] = resp["issuers"]
}
ws.renderTemplate(w, "pki.html", data)
}

View File

@@ -29,10 +29,12 @@ type WebServer struct {
// New creates a new WebServer. It dials the vault gRPC endpoint.
func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert)
logger.Info("connecting to vault", "addr", cfg.Web.VaultGRPC, "ca_cert", cfg.Web.VaultCACert)
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert, logger)
if err != nil {
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
}
logger.Info("vault connection ready", "addr", cfg.Web.VaultGRPC)
staticFS, err := fs.Sub(webui.FS, "static")
if err != nil {
@@ -47,9 +49,37 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
}, nil
}
// loggingMiddleware logs each incoming HTTP request.
func (ws *WebServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(lw, r)
ws.logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", lw.status,
"duration", time.Since(start),
"remote_addr", r.RemoteAddr,
)
})
}
// loggingResponseWriter wraps http.ResponseWriter to capture the status code.
type loggingResponseWriter struct {
http.ResponseWriter
status int
}
func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.status = code
lw.ResponseWriter.WriteHeader(code)
}
// Start starts the web server. It blocks until the server is closed.
func (ws *WebServer) Start() error {
r := chi.NewRouter()
r.Use(ws.loggingMiddleware)
ws.registerRoutes(r)
ws.httpSrv = &http.Server{

View File

@@ -0,0 +1,83 @@
syntax = "proto3";
package metacrypt.v2;
import "google/protobuf/timestamp.proto";
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
// ACMEService provides authenticated management of ACME state.
// These RPCs correspond to the REST management endpoints at /v2/acme/{mount}/.
// The ACME protocol endpoints themselves (/acme/{mount}/...) are HTTP-only
// per RFC 8555 and have no gRPC equivalents.
service ACMEService {
// CreateEAB creates External Account Binding credentials for the
// authenticated MCIAS user. The returned kid and hmac_key are used
// with any RFC 8555-compliant ACME client to register an account.
rpc CreateEAB(CreateEABRequest) returns (CreateEABResponse);
// SetConfig sets the ACME configuration for a CA mount.
// Currently configures the default issuer used for ACME certificate issuance.
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse);
// ListAccounts returns all ACME accounts for a CA mount. Admin only.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
// ListOrders returns all ACME orders for a CA mount. Admin only.
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
}
message CreateEABRequest {
string mount = 1;
}
message CreateEABResponse {
// kid is the key identifier to pass to the ACME client.
string kid = 1;
// hmac_key is the raw 32-byte HMAC-SHA256 key.
// Base64url-encode this value when configuring an ACME client.
bytes hmac_key = 2;
}
message SetConfigRequest {
string mount = 1;
// default_issuer is the name of the CA issuer to use for ACME certificates.
// The issuer must already exist on the CA mount.
string default_issuer = 2;
}
message SetConfigResponse {}
message ListAccountsRequest {
string mount = 1;
}
message ListAccountsResponse {
repeated ACMEAccount accounts = 1;
}
message ACMEAccount {
string id = 1;
string status = 2;
repeated string contact = 3;
string mcias_username = 4;
google.protobuf.Timestamp created_at = 5;
}
message ListOrdersRequest {
string mount = 1;
}
message ListOrdersResponse {
repeated ACMEOrder orders = 1;
}
message ACMEOrder {
string id = 1;
string account_id = 2;
string status = 3;
// identifiers are in "type:value" format, e.g. "dns:example.com".
repeated string identifiers = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp expires_at = 6;
}

View File

@@ -0,0 +1,34 @@
syntax = "proto3";
package metacrypt.v2;
import "google/protobuf/timestamp.proto";
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc TokenInfo(TokenInfoRequest) returns (TokenInfoResponse);
}
message LoginRequest {
string username = 1;
string password = 2;
string totp_code = 3;
}
message LoginResponse {
string token = 1;
google.protobuf.Timestamp expires_at = 2;
}
message LogoutRequest {}
message LogoutResponse {}
message TokenInfoRequest {}
message TokenInfoResponse {
string username = 1;
repeated string roles = 2;
bool is_admin = 3;
}

246
proto/metacrypt/v2/ca.proto Normal file
View File

@@ -0,0 +1,246 @@
syntax = "proto3";
package metacrypt.v2;
import "google/protobuf/timestamp.proto";
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
// CAService provides typed, authenticated access to CA engine operations.
// All RPCs require the service to be unsealed. Write operations (CreateIssuer,
// DeleteIssuer, ImportRoot, IssueCert, RenewCert) require authentication.
// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot) additionally
// require the caller to have admin privileges.
service CAService {
// ImportRoot imports an existing root CA certificate and private key.
// Admin only. Only allowed when no valid root exists.
rpc ImportRoot(ImportRootRequest) returns (ImportRootResponse);
// GetRoot returns the root CA certificate for a mount.
rpc GetRoot(GetRootRequest) returns (GetRootResponse);
// CreateIssuer creates a new intermediate CA issuer signed by the root.
// Admin only.
rpc CreateIssuer(CreateIssuerRequest) returns (CreateIssuerResponse);
// DeleteIssuer removes an issuer and its key material. Admin only.
rpc DeleteIssuer(DeleteIssuerRequest) returns (DeleteIssuerResponse);
// ListIssuers returns the names of all configured issuers. Auth required.
rpc ListIssuers(ListIssuersRequest) returns (ListIssuersResponse);
// GetIssuer returns the PEM-encoded certificate for a named issuer.
rpc GetIssuer(GetIssuerRequest) returns (GetIssuerResponse);
// GetChain returns the full PEM certificate chain for a named issuer.
rpc GetChain(CAServiceGetChainRequest) returns (CAServiceGetChainResponse);
// IssueCert issues a new leaf certificate from a named issuer. Auth required.
rpc IssueCert(IssueCertRequest) returns (IssueCertResponse);
// GetCert retrieves a certificate record by serial number. Auth required.
rpc GetCert(GetCertRequest) returns (GetCertResponse);
// ListCerts lists all certificate records for a mount. Auth required.
rpc ListCerts(ListCertsRequest) returns (ListCertsResponse);
// RenewCert renews an existing certificate, generating a new key and serial.
// Auth required.
rpc RenewCert(RenewCertRequest) returns (RenewCertResponse);
}
// --- ImportRoot ---
message ImportRootRequest {
string mount = 1;
// cert_pem is the PEM-encoded root CA certificate.
bytes cert_pem = 2;
// key_pem is the PEM-encoded private key for the root CA.
bytes key_pem = 3;
}
message ImportRootResponse {
string common_name = 1;
google.protobuf.Timestamp expires_at = 2;
}
// --- GetRoot ---
message GetRootRequest {
string mount = 1;
}
message GetRootResponse {
bytes cert_pem = 1;
}
// --- CreateIssuer ---
message CreateIssuerRequest {
string mount = 1;
// name is the unique identifier for this issuer within the mount.
string name = 2;
// key_algorithm overrides the mount-level default (e.g. "ecdsa", "rsa").
string key_algorithm = 3;
// key_size overrides the mount-level default (e.g. 256, 2048).
int32 key_size = 4;
// expiry is the lifetime of the issuer certificate (e.g. "26280h").
// Defaults to 3 years if empty.
string expiry = 5;
// max_ttl is the maximum TTL for leaf certificates issued by this issuer
// (e.g. "2160h"). Defaults to 90 days if empty.
string max_ttl = 6;
}
message CreateIssuerResponse {
string name = 1;
// cert_pem is the PEM-encoded issuer certificate.
bytes cert_pem = 2;
}
// --- DeleteIssuer ---
message DeleteIssuerRequest {
string mount = 1;
string name = 2;
}
message DeleteIssuerResponse {}
// --- ListIssuers ---
message ListIssuersRequest {
string mount = 1;
}
message ListIssuersResponse {
repeated string issuers = 1;
}
// --- GetIssuer ---
message GetIssuerRequest {
string mount = 1;
string name = 2;
}
message GetIssuerResponse {
bytes cert_pem = 1;
}
// --- GetChain ---
message CAServiceGetChainRequest {
string mount = 1;
string issuer = 2;
}
message CAServiceGetChainResponse {
// chain_pem contains the issuer certificate followed by the root certificate,
// PEM-encoded and concatenated.
bytes chain_pem = 1;
}
// --- IssueCert ---
message IssueCertRequest {
string mount = 1;
// issuer is the name of the issuer to sign the certificate.
string issuer = 2;
// profile selects the certificate profile (e.g. "server", "client").
// Defaults to "server" if empty.
string profile = 3;
string common_name = 4;
repeated string dns_names = 5;
repeated string ip_addresses = 6;
// ttl overrides the profile's default validity period (e.g. "8760h").
string ttl = 7;
// key_algorithm overrides the issuer-level default.
string key_algorithm = 8;
// key_size overrides the issuer-level default.
int32 key_size = 9;
repeated string key_usages = 10;
repeated string ext_key_usages = 11;
}
message IssueCertResponse {
string serial = 1;
string common_name = 2;
repeated string sans = 3;
string issued_by = 4;
google.protobuf.Timestamp expires_at = 5;
// cert_pem is the PEM-encoded leaf certificate.
bytes cert_pem = 6;
// key_pem is the PEM-encoded private key for the leaf certificate.
// This is the only time the private key is returned; it is not stored.
bytes key_pem = 7;
// chain_pem contains the full chain: leaf + issuer + root, PEM-concatenated.
bytes chain_pem = 8;
}
// --- GetCert ---
message GetCertRequest {
string mount = 1;
string serial = 2;
}
message GetCertResponse {
CertRecord cert = 1;
}
// --- ListCerts ---
message ListCertsRequest {
string mount = 1;
}
message ListCertsResponse {
repeated CertSummary certs = 1;
}
// --- RenewCert ---
message RenewCertRequest {
string mount = 1;
string serial = 2;
}
message RenewCertResponse {
string serial = 1;
string common_name = 2;
repeated string sans = 3;
string issued_by = 4;
google.protobuf.Timestamp expires_at = 5;
bytes cert_pem = 6;
bytes key_pem = 7;
bytes chain_pem = 8;
}
// --- Shared message types ---
// CertRecord is the full certificate record including the PEM-encoded cert.
message CertRecord {
string serial = 1;
string issuer = 2;
string common_name = 3;
repeated string sans = 4;
string profile = 5;
string issued_by = 6;
google.protobuf.Timestamp issued_at = 7;
google.protobuf.Timestamp expires_at = 8;
// cert_pem is the PEM-encoded certificate.
bytes cert_pem = 9;
}
// CertSummary is a lightweight certificate record without the PEM data,
// suitable for list responses.
message CertSummary {
string serial = 1;
string issuer = 2;
string common_name = 3;
string profile = 4;
string issued_by = 5;
google.protobuf.Timestamp issued_at = 6;
google.protobuf.Timestamp expires_at = 7;
}

View File

@@ -0,0 +1,8 @@
syntax = "proto3";
package metacrypt.v2;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
// Common message types shared across metacrypt.v2 services.
// Currently empty; reserved for future shared types.

View File

@@ -0,0 +1,38 @@
syntax = "proto3";
package metacrypt.v2;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
// EngineService manages the lifecycle of engine mounts.
// In v2, typed service RPCs (e.g. CAService) replace the generic Execute RPC
// for all engine operations.
service EngineService {
rpc Mount(MountRequest) returns (MountResponse);
rpc Unmount(UnmountRequest) returns (UnmountResponse);
rpc ListMounts(ListMountsRequest) returns (ListMountsResponse);
}
message MountRequest {
string name = 1;
string type = 2;
// config holds engine-specific configuration as key-value string pairs.
map<string, string> config = 3;
}
message MountResponse {}
message UnmountRequest {
string name = 1;
}
message UnmountResponse {}
message ListMountsRequest {}
message ListMountsResponse {
repeated MountInfo mounts = 1;
}
message MountInfo {
string name = 1;
string type = 2;
string mount_path = 3;
}

View File

@@ -0,0 +1,36 @@
syntax = "proto3";
package metacrypt.v2;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
// 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;
}

View File

@@ -0,0 +1,49 @@
syntax = "proto3";
package metacrypt.v2;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
service PolicyService {
rpc CreatePolicy(CreatePolicyRequest) returns (CreatePolicyResponse);
rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse);
rpc GetPolicy(GetPolicyRequest) returns (GetPolicyResponse);
rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse);
}
message PolicyRule {
string id = 1;
int32 priority = 2;
string effect = 3;
repeated string usernames = 4;
repeated string roles = 5;
repeated string resources = 6;
repeated string actions = 7;
}
message CreatePolicyRequest {
PolicyRule rule = 1;
}
message CreatePolicyResponse {
PolicyRule rule = 1;
}
message ListPoliciesRequest {}
message ListPoliciesResponse {
repeated PolicyRule rules = 1;
}
message GetPolicyRequest {
string id = 1;
}
message GetPolicyResponse {
PolicyRule rule = 1;
}
message DeletePolicyRequest {
string id = 1;
}
message DeletePolicyResponse {}

View File

@@ -0,0 +1,36 @@
syntax = "proto3";
package metacrypt.v2;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2";
service SystemService {
rpc Status(StatusRequest) returns (StatusResponse);
rpc Init(InitRequest) returns (InitResponse);
rpc Unseal(UnsealRequest) returns (UnsealResponse);
rpc Seal(SealRequest) returns (SealResponse);
}
message StatusRequest {}
message StatusResponse {
string state = 1;
}
message InitRequest {
string password = 1;
}
message InitResponse {
string state = 1;
}
message UnsealRequest {
string password = 1;
}
message UnsealResponse {
string state = 1;
}
message SealRequest {}
message SealResponse {
string state = 1;
}

View File

@@ -0,0 +1,60 @@
{{define "title"}} - Issuer: {{.IssuerName}}{{end}}
{{define "content"}}
<h2>Issuer: {{.IssuerName}}</h2>
<p>
<a href="/pki">&larr; PKI: {{.MountName}}</a>
&nbsp;&mdash;&nbsp;
<a href="/pki/{{.IssuerName}}" download="{{.IssuerName}}.pem">Download Issuer Cert (PEM)</a>
</p>
<h3>Certificates</h3>
<form method="get" action="/pki/issuer/{{.IssuerName}}" style="margin-bottom: 1rem;">
<div class="form-row">
<div class="form-group">
<label for="name_filter">Filter by name</label>
<input type="text" id="name_filter" name="name" value="{{.NameFilter}}" placeholder="common name contains...">
</div>
<div class="form-group">
<label for="sort_by">Sort by</label>
<select id="sort_by" name="sort">
<option value="cn"{{if eq .SortBy "cn"}} selected{{end}}>Common Name</option>
<option value="expiry"{{if eq .SortBy "expiry"}} selected{{end}}>Expiry Date</option>
</select>
</div>
<div class="form-group" style="align-self: flex-end;">
<button type="submit">Apply</button>
</div>
</div>
</form>
{{if .Certs}}
<table>
<thead>
<tr>
<th>Common Name</th>
<th>Profile</th>
<th>Serial</th>
<th>Issued By</th>
<th>Issued At</th>
<th>Expires At</th>
</tr>
</thead>
<tbody>
{{range .Certs}}
<tr>
<td>{{index . "cn"}}</td>
<td>{{index . "profile"}}</td>
<td><code>{{index . "serial"}}</code></td>
<td>{{index . "issued_by"}}</td>
<td>{{index . "issued_at"}}</td>
<td>{{index . "expires_at"}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No certificates found{{if .NameFilter}} matching &ldquo;{{.NameFilter}}&rdquo;{{end}}.</p>
{{end}}
{{end}}

View File

@@ -62,7 +62,7 @@
<tbody>
{{range .Issuers}}
<tr>
<td>{{.}}</td>
<td><a href="/pki/issuer/{{.}}">{{.}}</a></td>
<td><a href="/pki/{{.}}" download="{{.}}.pem">Download Cert (PEM)</a></td>
</tr>
{{end}}
@@ -72,6 +72,72 @@
<p>No issuers configured.</p>
{{end}}
{{if and .HasRoot .Issuers}}
<h3>Issue Certificate</h3>
{{if .IssuedCert}}
<div class="success">
<p>Certificate issued successfully.</p>
<div class="form-group">
<label>Certificate PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "cert_pem"}}</textarea>
</div>
<div class="form-group">
<label>Private Key PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "key_pem"}}</textarea>
</div>
<div class="form-group">
<label>Chain PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "chain_pem"}}</textarea>
</div>
<p><small>Serial: {{index .IssuedCert "serial"}} &mdash; Expires: {{index .IssuedCert "expires_at"}}</small></p>
</div>
{{else}}
<form method="post" action="/pki/issue">
<div class="form-row">
<div class="form-group">
<label for="issue_cn">Common Name</label>
<input type="text" id="issue_cn" name="common_name" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="issue_issuer">Issuer</label>
<select id="issue_issuer" name="issuer" required>
<option value="">-- select issuer --</option>
{{range .Issuers}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="issue_profile">Profile</label>
<select id="issue_profile" name="profile">
<option value="server">server (default)</option>
<option value="client">client</option>
<option value="peer">peer</option>
</select>
</div>
<div class="form-group">
<label for="issue_ttl">TTL (optional)</label>
<input type="text" id="issue_ttl" name="ttl" placeholder="2160h">
</div>
</div>
<details>
<summary>SANs</summary>
<div class="form-row">
<div class="form-group">
<label for="issue_dns">DNS Names (one per line)</label>
<textarea id="issue_dns" name="dns_names" rows="3" placeholder="example.com&#10;www.example.com"></textarea>
</div>
<div class="form-group">
<label for="issue_ips">IP Addresses (one per line)</label>
<textarea id="issue_ips" name="ip_addresses" rows="3" placeholder="10.0.0.1"></textarea>
</div>
</div>
</details>
<button type="submit">Issue Certificate</button>
</form>
{{end}}
{{end}}
{{if .IsAdmin}}
{{if .HasRoot}}
<h3>Create Issuer</h3>