Checkpoint: grpc auth fix, issuer list/detail, v2 protos, architecture docs
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
[{"lang":"en","usageCount":6}]
|
||||
[{"lang":"en","usageCount":20}]
|
||||
@@ -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
|
||||
|
||||
44
Dockerfile
44
Dockerfile
@@ -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
35
Dockerfile.api
Normal 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
33
Dockerfile.web
Normal 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"]
|
||||
7
Makefile
7
Makefile
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
83
proto/metacrypt/v2/acme.proto
Normal file
83
proto/metacrypt/v2/acme.proto
Normal 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;
|
||||
}
|
||||
34
proto/metacrypt/v2/auth.proto
Normal file
34
proto/metacrypt/v2/auth.proto
Normal 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
246
proto/metacrypt/v2/ca.proto
Normal 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;
|
||||
}
|
||||
8
proto/metacrypt/v2/common.proto
Normal file
8
proto/metacrypt/v2/common.proto
Normal 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.
|
||||
38
proto/metacrypt/v2/engine.proto
Normal file
38
proto/metacrypt/v2/engine.proto
Normal 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;
|
||||
}
|
||||
36
proto/metacrypt/v2/pki.proto
Normal file
36
proto/metacrypt/v2/pki.proto
Normal 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;
|
||||
}
|
||||
49
proto/metacrypt/v2/policy.proto
Normal file
49
proto/metacrypt/v2/policy.proto
Normal 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 {}
|
||||
36
proto/metacrypt/v2/system.proto
Normal file
36
proto/metacrypt/v2/system.proto
Normal 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;
|
||||
}
|
||||
60
web/templates/issuer_detail.html
Normal file
60
web/templates/issuer_detail.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{{define "title"}} - Issuer: {{.IssuerName}}{{end}}
|
||||
{{define "content"}}
|
||||
<h2>Issuer: {{.IssuerName}}</h2>
|
||||
|
||||
<p>
|
||||
<a href="/pki">← PKI: {{.MountName}}</a>
|
||||
—
|
||||
<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 “{{.NameFilter}}”{{end}}.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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"}} — 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 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>
|
||||
|
||||
Reference in New Issue
Block a user