mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
Squashes all pre-rewrite history into a single root commit. Previous tree: - 14506 commits including upstream Infisical fork history - Multiple merge chains carrying pre-purge content Force-rewrite per disk-space reclamation discipline. GHCR images (1.9.9, 1.9.11) remain immutable; their git lineage is no longer materialized.
972 lines
37 KiB
Go
972 lines
37 KiB
Go
// Command kms starts the KMS server backed by ZapDB + MPC.
|
|
//
|
|
// Configuration precedence: flags > env vars > defaults.
|
|
//
|
|
// Boot is fail-open: if MPC_VAULT_ID is set but the MPC daemon is
|
|
// unreachable, KMS logs a warning and continues in secrets-only mode.
|
|
// /healthz reports `status=degraded` and any /v1/kms/keys/* request
|
|
// returns 503 with body `{"error":"mpc unreachable","mode":"secrets-only"}`.
|
|
// Each request re-probes MPC, so the same pod recovers transparently
|
|
// once MPC comes back up — no restart required.
|
|
//
|
|
// Env vars:
|
|
// MPC_ADDR - ZAP address (host:port); empty = mDNS discovery
|
|
// MPC_VAULT_ID - MPC vault ID for validator keys (required for MPC)
|
|
// KMS_NODE_ID - ZAP node ID (default "kms-0")
|
|
// ZAP_PORT - ZAP secrets-server listen port (default 9999, 0 = disable)
|
|
// MPC_REK_ENDPOINT - MPC ZAP CSV for threshold-rooted Root Encryption Key
|
|
// fetch. When set, kmsd refuses to start unless the
|
|
// MPC cluster returns a 32-byte REK. Takes precedence
|
|
// over KMS_MASTER_KEY_B64.
|
|
// MPC_REK_KEY_ID - MPC-side identifier of the wrapped REK record
|
|
// (default "kms/rek/v1"). Bump alongside reshare.
|
|
// MPC_REK_TIMEOUT - REK bootstrap timeout (Go duration, default "10s").
|
|
// KMS_MASTER_KEY_B64 - LEGACY 32-byte master key (base64). Used only when
|
|
// MPC_REK_ENDPOINT is unset. Slated for removal once
|
|
// every KMS deployment ships with MPC-rooted REK.
|
|
// KMS_DATA_DIR - ZapDB data directory (default "/data/kms")
|
|
// KMS_LISTEN - HTTP listen address (default ":8080")
|
|
// IAM_ENDPOINT - Hanzo IAM endpoint for auth (default "https://hanzo.id")
|
|
// KMS_CONSENSUS_VALIDATORS - newline-separated NodeIDs of the validator
|
|
// authority (consensus read set). Required for
|
|
// ZAP. Format: one cb58 NodeID per line.
|
|
// KMS_CONSENSUS_OPERATORS - newline-separated NodeIDs of the operator
|
|
// authority (consensus write set). Required
|
|
// for ZAP. Same format.
|
|
// KMS_CONSENSUS_FILE - optional file path supplying the same two
|
|
// sets (JSON: {"validators":[...],"operators":[...]}).
|
|
// When set, env vars above are ignored.
|
|
// KMS_CONSENSUS_TTL - per-authority refresh TTL (Go duration,
|
|
// default 30s). The kms-operator re-applies
|
|
// the snapshot at this cadence.
|
|
// KMS_NONCE_LEDGER_TTL - anti-replay nonce TTL (Go duration,
|
|
// default MaxClockSkew+1m=6m). Captured
|
|
// envelopes are rejected as replays while
|
|
// their (NodeID,Nonce) tuple is in the
|
|
// ledger; after TTL they fail clock-skew
|
|
// anyway. Tune up only if MaxClockSkew is
|
|
// tuned up.
|
|
// KMS_NONCE_LEDGER_GC - background GC sweep cadence (Go duration,
|
|
// default TTL/4 = 90s). Smaller values
|
|
// reduce peak memory; larger values reduce
|
|
// CPU burn. The ledger correctness does
|
|
// NOT depend on this — it's a memory/CPU
|
|
// trade only.
|
|
//
|
|
// S3 replication (ZapDB Replicator):
|
|
// REPLICATE_S3_ENDPOINT - S3 endpoint (empty = replication disabled)
|
|
// REPLICATE_S3_BUCKET - S3 bucket (default "lux-kms-backups")
|
|
// REPLICATE_S3_REGION - S3 region (default "us-central1")
|
|
// REPLICATE_S3_ACCESS_KEY
|
|
// REPLICATE_S3_SECRET_KEY
|
|
// REPLICATE_AGE_RECIPIENT - age public key for backup encryption
|
|
// REPLICATE_AGE_IDENTITY - age private key for restore
|
|
// REPLICATE_PATH - S3 key prefix (default "kms/{KMS_NODE_ID}")
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
badger "github.com/luxfi/zapdb"
|
|
|
|
"github.com/luxfi/kms/pkg/keys"
|
|
"github.com/luxfi/kms/pkg/mpc"
|
|
"github.com/luxfi/kms/pkg/store"
|
|
"github.com/luxfi/kms/pkg/store/mpcrek"
|
|
"github.com/luxfi/kms/pkg/zapserver"
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// One binary, one image. The KMS admin UI ships inside this binary.
|
|
// The `web/` directory at this package root is populated by `make build`
|
|
// (it copies `frontend/dist/` from the repo root before `go build`); the
|
|
// embed picks up the static Vite output and serves it at `/` with a SPA
|
|
// fallback so React Router routes resolve. API routes (`/v1/*`,
|
|
// `/healthz`, `/health`) take precedence and are registered before the
|
|
// catch-all. If `web/` is empty (e.g. fresh clone, no UI build yet), the
|
|
// fallback returns 404 cleanly — the API still works.
|
|
//
|
|
//go:embed all:web
|
|
var frontendDist embed.FS
|
|
|
|
func main() {
|
|
mpcAddr := envOr("MPC_ADDR", "")
|
|
vaultID := envOr("MPC_VAULT_ID", "")
|
|
nodeID := envOr("KMS_NODE_ID", "kms-0")
|
|
iamEndpoint := envOr("IAM_ENDPOINT", "https://hanzo.id")
|
|
dataDir := envOr("KMS_DATA_DIR", "/data/kms")
|
|
listen := envOr("KMS_LISTEN", ":8080")
|
|
|
|
// Open ZapDB.
|
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
|
log.Fatalf("kms: create data dir %s: %v", dataDir, err)
|
|
}
|
|
dbOpts := badger.DefaultOptions(dataDir).
|
|
WithLogger(zapdbLogger{}).
|
|
WithEncryptionKey(masterKeyFromEnv()).
|
|
WithIndexCacheSize(64 << 20) // 64MB index cache
|
|
db, err := badger.Open(dbOpts)
|
|
if err != nil {
|
|
log.Fatalf("kms: open zapdb at %s: %v", dataDir, err)
|
|
}
|
|
defer db.Close()
|
|
|
|
log.Printf("kms: zapdb opened at %s", dataDir)
|
|
|
|
// Start ZapDB Replicator if S3 is configured.
|
|
replicator := startReplicator(db, nodeID)
|
|
if replicator != nil {
|
|
defer replicator.Stop()
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// MPC availability flag. Flips to true after a successful ZAP probe at
|
|
// boot. Read by /healthz to surface degraded mode and by the keys
|
|
// routes to short-circuit with 503 when MPC is unreachable. The
|
|
// pointer-to-bool is set once in main and read concurrently — atomic
|
|
// load isn't necessary because the value never changes after boot.
|
|
var mpcAvailable bool
|
|
|
|
// Health probes — wired in every shape callers might try:
|
|
// /healthz / /health — root, for direct/standalone probes
|
|
// /v1/kms/healthz / /v1/kms/health — gateway-routed, no prefix strip
|
|
// All return the same shape so probes are interchangeable.
|
|
healthOK := healthHandler(vaultID, &mpcAvailable)
|
|
mux.HandleFunc("GET /healthz", healthOK)
|
|
mux.HandleFunc("GET /health", healthOK)
|
|
mux.HandleFunc("GET /v1/kms/healthz", healthOK)
|
|
mux.HandleFunc("GET /v1/kms/health", healthOK)
|
|
|
|
// SPA bootstrap config. The Infisical-derived React frontend in
|
|
// frontend/src/hooks/api/admin/queries.ts:fetchServerConfig fetches
|
|
// `/v1/admin/config` at first paint and refuses to render when the
|
|
// payload is missing — the user sees `["server-config"] data is
|
|
// undefined`. We don't run the full Infisical admin surface, so
|
|
// this returns a minimal-but-complete shape: signups via IAM, no
|
|
// invite-only gating, no SMTP, instance is initialized. Fields the
|
|
// SPA reads: initialized, allowSignUp, allowedSignUpDomain, etc.
|
|
// defaultAuthOrgSlug drives the OIDC "Login with <Brand>" button in
|
|
// the SPA: when set, the SPA bypasses the org-picker and goes straight
|
|
// to /v1/sso/oidc/login?orgSlug=<this>. Read from env so the same image
|
|
// white-labels per-deployment via KMS_DEFAULT_ORG_SLUG.
|
|
defaultOrgSlug := envOr("KMS_DEFAULT_ORG_SLUG", "")
|
|
mux.HandleFunc("GET /v1/admin/config", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"config": map[string]any{
|
|
"initialized": true,
|
|
"allowSignUp": true,
|
|
"allowedSignUpDomain": nil,
|
|
"trustSamlEmails": false,
|
|
"trustLdapEmails": false,
|
|
"trustOidcEmails": true,
|
|
"defaultAuthOrgId": "",
|
|
"defaultAuthOrgSlug": defaultOrgSlug,
|
|
"isSecretScanningDisabled": false,
|
|
"isMigrationModeOn": false,
|
|
"enabledLoginMethods": []string{"oidc"},
|
|
"slackClientId": "",
|
|
"isSmtpConfigured": false,
|
|
"isSecretApprovalDisabled": false,
|
|
"identityRevocationEnabled": true,
|
|
},
|
|
})
|
|
})
|
|
|
|
// Machine identity auth via IAM.
|
|
mux.HandleFunc("POST /v1/kms/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ClientID == "" || req.ClientSecret == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"statusCode": 400, "message": "clientId and clientSecret required"})
|
|
return
|
|
}
|
|
form := url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {req.ClientID},
|
|
"client_secret": {req.ClientSecret},
|
|
}
|
|
// Canonical OAuth2 path on hanzoai/iam — no `/api/` prefix (killed in
|
|
// v2.381.0). `/login/oauth/access_token` is mounted at root per the
|
|
// OAuth2 spec; `/oauth/access_token` is also wired as an alias.
|
|
resp, err := http.PostForm(iamEndpoint+"/login/oauth/access_token", form)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"statusCode": 502, "message": "identity provider unreachable"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
var tok map[string]any
|
|
json.NewDecoder(resp.Body).Decode(&tok)
|
|
at, _ := tok["access_token"].(string)
|
|
if at == "" {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"statusCode": 401, "message": "invalid credentials"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"accessToken": at, "expiresIn": 86400, "tokenType": "Bearer"})
|
|
})
|
|
|
|
// Secret store — ZapDB-backed, encrypted at rest.
|
|
secStore := store.NewSecretStore(db)
|
|
|
|
// JWT-backed authorization for the secrets surface. Every request to
|
|
// /v1/kms/orgs/{org}/secrets/* must carry an IAM-signed bearer token
|
|
// whose `owner` claim equals {org} (or whose roles include
|
|
// kms-admin). Public endpoints (health, /v1/kms/auth/login, OIDC
|
|
// SSO, the SPA) are NOT wrapped — they remain reachable without a
|
|
// token. See auth.go.
|
|
//
|
|
// JWKS is fetched from KMS_IAM_URL (in-cluster) and the JWT `iss`
|
|
// claim is validated against KMS_EXPECTED_ISSUER (public hostname).
|
|
// When the deployment uses a single URL for both, fall through to
|
|
// IAM_ENDPOINT.
|
|
jwksFrom := envOr("KMS_IAM_URL", iamEndpoint)
|
|
expectedIss := envOr("KMS_EXPECTED_ISSUER", iamEndpoint)
|
|
auth := newOrgJWTAuth(jwksFrom, expectedIss)
|
|
|
|
// GET /v1/kms/orgs/{org}/secrets/{path...}/{name}
|
|
// Matches the ATS kmsclient.Get() URL pattern.
|
|
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
|
|
rest := r.PathValue("rest")
|
|
idx := strings.LastIndex(rest, "/")
|
|
if idx < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "path and name required"})
|
|
return
|
|
}
|
|
path, name := rest[:idx], rest[idx+1:]
|
|
env := r.URL.Query().Get("env")
|
|
if env == "" {
|
|
env = "default"
|
|
}
|
|
sec, err := secStore.Get(path, name, env)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"secret": map[string]any{"value": string(sec.Ciphertext)},
|
|
})
|
|
}))
|
|
|
|
// POST /v1/kms/orgs/{org}/secrets — create a secret.
|
|
mux.HandleFunc("POST /v1/kms/orgs/{org}/secrets", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Env string `json:"env"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "name and value required"})
|
|
return
|
|
}
|
|
if req.Env == "" {
|
|
req.Env = "default"
|
|
}
|
|
sec := &store.Secret{
|
|
Name: req.Name,
|
|
Path: req.Path,
|
|
Env: req.Env,
|
|
Ciphertext: []byte(req.Value),
|
|
}
|
|
if err := secStore.Put(sec); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"message": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
|
|
}))
|
|
|
|
// DELETE /v1/kms/orgs/{org}/secrets/{rest...}/{name}
|
|
mux.HandleFunc("DELETE /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
|
|
rest := r.PathValue("rest")
|
|
idx := strings.LastIndex(rest, "/")
|
|
if idx < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "path and name required"})
|
|
return
|
|
}
|
|
path, name := rest[:idx], rest[idx+1:]
|
|
env := r.URL.Query().Get("env")
|
|
if env == "" {
|
|
env = "default"
|
|
}
|
|
if err := secStore.Delete(path, name, env); err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}))
|
|
|
|
// Legacy: env-backed secret fetch.
|
|
mux.HandleFunc("GET /v1/kms/secrets/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "secret name required"})
|
|
return
|
|
}
|
|
val := os.Getenv(name)
|
|
if val == "" {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"secret": map[string]any{"secretKey": name, "secretValue": val},
|
|
})
|
|
})
|
|
|
|
// MPC key management (only when MPC_VAULT_ID is set).
|
|
//
|
|
// Boot is fail-open: every error path here logs a warning and degrades
|
|
// to secrets-only mode instead of exiting. The previous behaviour
|
|
// (log.Fatalf on any ZAP init or status failure) meant a transient MPC
|
|
// outage took down KMS too — the secrets surface is independent and
|
|
// must keep serving. Routes that require MPC return 503 via the
|
|
// mpcAvailable flag wired into healthOK / registerKMSRoutes.
|
|
if vaultID != "" {
|
|
// Trust at the network boundary (NetworkPolicy + ZAP wire).
|
|
zapClient, err := mpc.NewZapClient(nodeID, mpcAddr)
|
|
switch {
|
|
case err != nil:
|
|
log.Printf("kms: WARNING: mpc zap client init failed: %v — degrading to secrets-only mode", err)
|
|
// Even though no client could be built, the key routes must
|
|
// still respond with a clear 503 — without this, callers see
|
|
// 405/404 and can't distinguish "MPC down" from "wrong path"
|
|
// or "wrong method". registerStubKMSRoutes installs handlers
|
|
// that always 503 with the same body shape as registerKMSRoutes.
|
|
registerStubKMSRoutes(mux, err)
|
|
default:
|
|
keyStore, ksErr := store.New(db)
|
|
if ksErr != nil {
|
|
log.Printf("kms: WARNING: mpc key store init failed: %v — degrading to secrets-only mode", ksErr)
|
|
zapClient.Close()
|
|
registerStubKMSRoutes(mux, ksErr)
|
|
break
|
|
}
|
|
|
|
checkCtx, checkCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
status, statusErr := zapClient.Status(checkCtx)
|
|
checkCancel()
|
|
if statusErr != nil {
|
|
log.Printf("kms: WARNING: mpc unreachable via ZAP: %v — degrading to secrets-only mode", statusErr)
|
|
// Keep the client + key routes wired even though the probe
|
|
// failed: MPC may come up later in the same pod lifetime
|
|
// (e.g. NetworkPolicy applied after KMS started). Routes
|
|
// re-probe on every call; if MPC is up by then they
|
|
// succeed transparently.
|
|
} else {
|
|
log.Printf("kms: mpc ready=%v peers=%d/%d mode=%s",
|
|
status.Ready, status.ConnectedPeers, status.ExpectedPeers, status.Mode)
|
|
mpcAvailable = true
|
|
}
|
|
mgr := keys.NewManager(zapClient, keyStore, vaultID)
|
|
registerKMSRoutes(mux, mgr, zapClient, &mpcAvailable)
|
|
}
|
|
}
|
|
if vaultID == "" {
|
|
log.Printf("kms: MPC_VAULT_ID not set — running in secrets-only mode (no threshold signing)")
|
|
}
|
|
|
|
// ZAP secrets server — exposes the SecretStore over luxfi/zap on its own
|
|
// port so in-cluster callers can fetch with zero REST round-trip.
|
|
//
|
|
// The master key (Root Encryption Key) protecting every per-secret DEK is
|
|
// resolved by loadREK below. Preferred source is a luxfi/mpc threshold
|
|
// cluster (MPC_REK_ENDPOINT); fallback is KMS_MASTER_KEY_B64 for the
|
|
// migration window. If MPC_REK_ENDPOINT is set, kmsd FAILS CLOSED on any
|
|
// fetch error — there is no env-var fallback in MPC mode, since the
|
|
// whole point of MPC-rooting the REK is that it must not be derivable
|
|
// from anything on the pod.
|
|
zapPortStr := envOr("ZAP_PORT", "9999")
|
|
zapPort, _ := strconv.Atoi(zapPortStr)
|
|
masterKey := loadREK()
|
|
defer mpcrek.Zero(masterKey)
|
|
if masterKey != nil && zapPort > 0 {
|
|
n := zap.NewNode(zap.NodeConfig{
|
|
NodeID: nodeID + "-secrets",
|
|
ServiceType: "_kms._tcp",
|
|
Port: zapPort,
|
|
})
|
|
if err := n.Start(); err != nil {
|
|
log.Printf("kms: ZAP secrets-server failed to start on :%d: %v", zapPort, err)
|
|
} else {
|
|
authorizer, err := buildConsensusAuthorizer()
|
|
if err != nil {
|
|
// Fail-closed: a misconfigured authorizer is a wire-
|
|
// reachable security hole. Refuse to boot.
|
|
log.Fatalf("kms: consensus authorizer init failed: %v", err)
|
|
}
|
|
nonceLedger, err := buildNonceLedger()
|
|
if err != nil {
|
|
// Same fail-closed posture: a misconfigured ledger
|
|
// silently re-opens the replay window. Refuse to
|
|
// boot rather than ship a downgrade.
|
|
log.Fatalf("kms: nonce ledger init failed: %v", err)
|
|
}
|
|
zs := zapserver.New(zapserver.Config{
|
|
Store: secStore,
|
|
MasterKey: masterKey,
|
|
Authorizer: authorizer,
|
|
NonceLedger: nonceLedger,
|
|
Logger: luxlog.New("component", "kms-zapserver"),
|
|
})
|
|
zs.Register(n)
|
|
log.Printf("kms: ZAP secrets-server listening on :%d (service=_kms._tcp)", zapPort)
|
|
}
|
|
} else {
|
|
log.Printf("kms: ZAP secrets-server disabled (set MPC_REK_ENDPOINT or KMS_MASTER_KEY_B64 and ZAP_PORT to enable)")
|
|
}
|
|
|
|
// IAM OIDC SSO — /v1/sso/oidc/{login,callback}, /v1/sso/whoami, /v1/sso/logout.
|
|
// Registered before the SPA catch-all; if OIDC isn't configured the
|
|
// handlers return 503 so misconfiguration is observable.
|
|
registerOIDCRoutes(mux)
|
|
|
|
// Mount the embedded admin UI under the root catch-all. Registered last
|
|
// so explicit handlers (`/healthz`, `/health`, `/v1/*`) win the route
|
|
// match. SPA fallback: any GET that doesn't match a real file falls
|
|
// back to index.html so React Router can resolve the path client-side.
|
|
registerWebUI(mux)
|
|
|
|
// Start HTTP server.
|
|
srv := &http.Server{
|
|
Addr: listen,
|
|
Handler: mux,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
log.Printf("kms: HTTP listening on %s", listen)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("kms: http: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Graceful shutdown.
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sig
|
|
log.Println("kms: shutting down...")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(ctx)
|
|
}
|
|
|
|
// registerStubKMSRoutes installs 503-only handlers for every /v1/kms/keys/*
|
|
// route. Used when the boot-time MPC client could not be constructed at
|
|
// all (e.g. dial timed out, ZAP node init failed). Without this, GET
|
|
// /v1/kms/keys/{id} fell through to the SPA catch-all and returned the
|
|
// admin UI HTML; POST returned 405 from the muxer's "no method match"
|
|
// path. Both are useless to callers — they need a single, parseable
|
|
// signal that MPC is down so retry / circuit-break logic kicks in.
|
|
//
|
|
// The body shape matches registerKMSRoutes' requireMPC 503 exactly so
|
|
// callers don't need to branch on "stub vs gated" — they see one
|
|
// `{"error":"mpc unreachable","mode":"secrets-only","detail":"..."}`
|
|
// response across both code paths.
|
|
func registerStubKMSRoutes(mux *http.ServeMux, bootErr error) {
|
|
stub := func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "mpc unreachable",
|
|
"detail": bootErr.Error(),
|
|
"mode": "secrets-only",
|
|
})
|
|
}
|
|
mux.HandleFunc("POST /v1/kms/keys/generate", stub)
|
|
mux.HandleFunc("GET /v1/kms/keys", stub)
|
|
mux.HandleFunc("GET /v1/kms/keys/{id}", stub)
|
|
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", stub)
|
|
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", stub)
|
|
mux.HandleFunc("GET /v1/kms/status", stub)
|
|
}
|
|
|
|
// healthHandler returns the /healthz handler.
|
|
//
|
|
// Health is intentionally HTTP 200 even when MPC is unreachable: the
|
|
// secrets surface (ZAP secrets-server, /v1/kms/orgs/.../secrets, IAM
|
|
// SSO, /v1/kms/auth/login) is fully functional in secrets-only mode.
|
|
// Routes that require MPC (/v1/kms/keys/*) self-report 503 instead.
|
|
// K8s readiness gating off /healthz would otherwise pull a working
|
|
// secrets-only KMS out of rotation purely because MPC is in upgrade.
|
|
//
|
|
// When MPC is enabled in spec but unreachable, the body switches to
|
|
// `{"status":"degraded","mpc":"unreachable","detail":"..."}` so probes
|
|
// that scrape the body still observe the degraded mode without
|
|
// flapping the pod out of service.
|
|
func healthHandler(vaultID string, mpcAvailable *bool) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body := map[string]string{"status": "ok", "service": "kms"}
|
|
if vaultID != "" && (mpcAvailable == nil || !*mpcAvailable) {
|
|
body["status"] = "degraded"
|
|
body["mpc"] = "unreachable"
|
|
body["detail"] = "secrets-only mode; signing routes return 503"
|
|
}
|
|
writeJSON(w, http.StatusOK, body)
|
|
}
|
|
}
|
|
|
|
func registerKMSRoutes(mux *http.ServeMux, mgr *keys.Manager, mpcBackend keys.MPCBackend, mpcAvailable *bool) {
|
|
// KMS key routes are unprotected at the HTTP layer — auth is enforced
|
|
// by the Gateway (JWT validation + X-IAM-Roles header injection).
|
|
// In-cluster callers (ATS, BD, TA) go through Gateway; direct access
|
|
// is blocked by K8s NetworkPolicy (only Gateway can reach port 8080).
|
|
//
|
|
// requireMPC short-circuits with 503 + a re-probe attempt when the
|
|
// boot-time MPC handshake failed. The re-probe lets KMS recover
|
|
// transparently if MPC came up after KMS did (common during rollout
|
|
// or NetworkPolicy reconcile races); a single 5s status call is
|
|
// cheap relative to keygen/sign latency.
|
|
requireMPC := func(w http.ResponseWriter, r *http.Request) bool {
|
|
if mpcAvailable != nil && *mpcAvailable {
|
|
return true
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
if _, err := mpcBackend.Status(probeCtx); err != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "mpc unreachable",
|
|
"detail": err.Error(),
|
|
"mode": "secrets-only",
|
|
})
|
|
return false
|
|
}
|
|
if mpcAvailable != nil {
|
|
*mpcAvailable = true
|
|
}
|
|
return true
|
|
}
|
|
|
|
mux.HandleFunc("POST /v1/kms/keys/generate", func(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMPC(w, r) {
|
|
return
|
|
}
|
|
var req keys.GenerateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if req.ValidatorID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "validator_id is required"})
|
|
return
|
|
}
|
|
if req.Threshold < 2 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "threshold must be >= 2"})
|
|
return
|
|
}
|
|
if req.Parties < req.Threshold {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "parties must be >= threshold"})
|
|
return
|
|
}
|
|
if req.Threshold == req.Parties {
|
|
log.Printf("kms: WARNING: keygen threshold==parties (%d) for validator=%s — no fault tolerance",
|
|
req.Threshold, req.ValidatorID)
|
|
}
|
|
|
|
ks, err := mgr.GenerateValidatorKeys(r.Context(), req)
|
|
if err != nil {
|
|
log.Printf("kms: audit: keygen FAILED validator_id=%s error=%v", req.ValidatorID, err)
|
|
if strings.Contains(err.Error(), "already exists") {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
log.Printf("kms: audit: keygen OK validator_id=%s bls_wallet=%s corona_wallet=%s threshold=%d parties=%d",
|
|
ks.ValidatorID, ks.BLSWalletID, ks.CoronaWalletID, ks.Threshold, ks.Parties)
|
|
writeJSON(w, http.StatusCreated, ks)
|
|
})
|
|
|
|
mux.HandleFunc("GET /v1/kms/keys", func(w http.ResponseWriter, r *http.Request) {
|
|
list := mgr.List()
|
|
if list == nil {
|
|
list = []*keys.ValidatorKeySet{}
|
|
}
|
|
writeJSON(w, http.StatusOK, list)
|
|
})
|
|
|
|
mux.HandleFunc("GET /v1/kms/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
ks, err := mgr.Get(id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "validator key set not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ks)
|
|
})
|
|
|
|
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", func(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMPC(w, r) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
var req keys.SignRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if len(req.Message) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
|
|
return
|
|
}
|
|
|
|
var resp *keys.SignResponse
|
|
var err error
|
|
switch req.KeyType {
|
|
case "bls":
|
|
resp, err = mgr.SignWithBLS(r.Context(), id, req.Message)
|
|
case "corona":
|
|
resp, err = mgr.SignWithCorona(r.Context(), id, req.Message)
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key_type must be 'bls' or 'corona'"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("kms: audit: sign FAILED validator_id=%s key_type=%s error=%v", id, req.KeyType, err)
|
|
if strings.Contains(err.Error(), "not found") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
log.Printf("kms: audit: sign OK validator_id=%s key_type=%s", id, req.KeyType)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMPC(w, r) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
var req keys.RotateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if req.NewThreshold == 0 && len(req.NewParticipants) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new_threshold or new_participants required"})
|
|
return
|
|
}
|
|
|
|
ks, err := mgr.Rotate(r.Context(), id, req)
|
|
if err != nil {
|
|
log.Printf("kms: audit: rotate FAILED validator_id=%s error=%v", id, err)
|
|
if strings.Contains(err.Error(), "not found") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
log.Printf("kms: audit: rotate OK validator_id=%s new_threshold=%d new_parties=%d",
|
|
id, ks.Threshold, ks.Parties)
|
|
writeJSON(w, http.StatusOK, ks)
|
|
})
|
|
|
|
mux.HandleFunc("GET /v1/kms/status", func(w http.ResponseWriter, r *http.Request) {
|
|
status, err := mpcBackend.Status(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"kms": "ok",
|
|
"mpc": "unreachable",
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"kms": "ok",
|
|
"mpc": status,
|
|
})
|
|
})
|
|
}
|
|
|
|
// startReplicator initializes ZapDB S3 replication if configured.
|
|
//
|
|
// REPLICATE_S3_ENDPOINT is normalized to bare host:port — s3.New
|
|
// rejects fully-qualified URLs ("Endpoint url cannot have fully
|
|
// qualified paths") and silently disables replication when an operator
|
|
// passes a scheme- or path-bearing value. We parse defensively and
|
|
// drop both the scheme and any path component, logging a warning so
|
|
// the misconfiguration is visible.
|
|
func startReplicator(db *badger.DB, nodeID string) *badger.Replicator {
|
|
rawEndpoint := os.Getenv("REPLICATE_S3_ENDPOINT")
|
|
if rawEndpoint == "" {
|
|
log.Printf("kms: S3 replication disabled (set REPLICATE_S3_ENDPOINT to enable)")
|
|
return nil
|
|
}
|
|
|
|
endpoint, useSSL := normalizeS3Endpoint(rawEndpoint)
|
|
|
|
// Backwards-compatible env reads: AWS SDK names take precedence,
|
|
// REPLICATE_S3_* legacy names are honoured, and the historical
|
|
// REPLICATE_S3_ACCESS_KEY / _SECRET_KEY shorthand still wins as a
|
|
// last resort. Operators should pick one — we accept all three so
|
|
// a stale chart cannot silently zero-out credentials.
|
|
access := firstNonEmpty(
|
|
os.Getenv("REPLICATE_S3_ACCESS_KEY_ID"),
|
|
os.Getenv("AWS_ACCESS_KEY_ID"),
|
|
os.Getenv("REPLICATE_S3_ACCESS_KEY"),
|
|
)
|
|
secret := firstNonEmpty(
|
|
os.Getenv("REPLICATE_S3_SECRET_ACCESS_KEY"),
|
|
os.Getenv("AWS_SECRET_ACCESS_KEY"),
|
|
os.Getenv("REPLICATE_S3_SECRET_KEY"),
|
|
)
|
|
|
|
// Backup interval. Default 60s — every-second backups generated
|
|
// continuous allocation pressure (~3 KiB streamed per cycle plus
|
|
// the encrypter's working buffers, ~150 MiB sustained heap on
|
|
// quiet KMS pods). 60s catches up to mainnet write rate (~10
|
|
// secret rotations/day) with plenty of headroom; operators can
|
|
// tune via REPLICATE_INTERVAL=<go-duration>.
|
|
intv := time.Minute
|
|
if v := os.Getenv("REPLICATE_INTERVAL"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
|
intv = d
|
|
}
|
|
}
|
|
cfg := badger.ReplicatorConfig{
|
|
Endpoint: endpoint,
|
|
Bucket: envOr("REPLICATE_S3_BUCKET", "lux-kms-backups"),
|
|
Region: envOr("REPLICATE_S3_REGION", "us-central1"),
|
|
AccessKey: access,
|
|
SecretKey: secret,
|
|
UseSSL: useSSL,
|
|
Path: envOr("REPLICATE_S3_PATH", envOr("REPLICATE_PATH", fmt.Sprintf("kms/%s", nodeID))),
|
|
Interval: intv,
|
|
}
|
|
|
|
// Age encryption for backups.
|
|
recipientStr := os.Getenv("REPLICATE_AGE_RECIPIENT")
|
|
if recipientStr != "" {
|
|
// Age recipient parsing happens inside the Replicator via the age package.
|
|
// The ReplicatorConfig accepts the raw recipient/identity interfaces.
|
|
log.Printf("kms: S3 replication with age encryption enabled")
|
|
}
|
|
|
|
replicator, err := badger.NewReplicator(db, cfg)
|
|
if err != nil {
|
|
log.Printf("kms: WARNING: S3 replicator init failed: %v — replication disabled", err)
|
|
return nil
|
|
}
|
|
|
|
go replicator.Start(context.Background())
|
|
log.Printf("kms: S3 replication started → %s/%s/%s", endpoint, cfg.Bucket, cfg.Path)
|
|
return replicator
|
|
}
|
|
|
|
// loadREK returns the 32-byte Root Encryption Key used by the ZAP secrets
|
|
// server to seal every per-secret DEK.
|
|
//
|
|
// Preference order:
|
|
// 1. MPC_REK_ENDPOINT set → fetch via mpcrek.Bootstrap. FAIL-CLOSED on
|
|
// any error: log.Fatalf rather than silently falling back to the
|
|
// env-var path. This is the point of MPC-rooting the REK — if the
|
|
// pod can shrug off MPC unavailability and still come up with a
|
|
// key, we have re-introduced the static-secret weakness we set out
|
|
// to remove.
|
|
// 2. KMS_MASTER_KEY_B64 set → decode and return. Logged WARN so the
|
|
// deployment knows it's still on the legacy path.
|
|
// 3. Neither set → nil (ZAP secrets-server stays disabled).
|
|
//
|
|
// The returned slice is the live REK for the process lifetime. The
|
|
// caller MUST keep it alive until shutdown and arrange to zero it via
|
|
// mpcrek.Zero on the way out (see the defer in main).
|
|
func loadREK() []byte {
|
|
if endpoint := envOr("MPC_REK_ENDPOINT", ""); endpoint != "" {
|
|
keyID := envOr("MPC_REK_KEY_ID", "kms/rek/v1")
|
|
timeoutStr := envOr("MPC_REK_TIMEOUT", "10s")
|
|
timeout, perr := time.ParseDuration(timeoutStr)
|
|
if perr != nil {
|
|
log.Fatalf("kms: MPC_REK_TIMEOUT %q invalid: %v", timeoutStr, perr)
|
|
}
|
|
nodeID := envOr("KMS_NODE_ID", "kms-0") + "-rek-bootstrap"
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
log.Printf("kms: bootstrapping REK from MPC endpoint=%q keyID=%q", endpoint, keyID)
|
|
rek, err := mpcrek.Bootstrap(ctx, mpcrek.Config{
|
|
Endpoint: endpoint,
|
|
KeyID: keyID,
|
|
NodeID: nodeID,
|
|
Timeout: timeout,
|
|
})
|
|
if err != nil {
|
|
// Fail-closed. The deployment asked for MPC-rooted REK and
|
|
// MPC said no — refuse to start with a static key, that's
|
|
// the split-brain we are escaping.
|
|
log.Fatalf("kms: MPC REK bootstrap failed: %v", err)
|
|
}
|
|
log.Printf("kms: REK bootstrapped from MPC (32 bytes)")
|
|
return rek
|
|
}
|
|
|
|
b64 := envOr("KMS_MASTER_KEY_B64", "")
|
|
if b64 == "" {
|
|
return nil
|
|
}
|
|
mk, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil || len(mk) != 32 {
|
|
log.Printf("kms: KMS_MASTER_KEY_B64 invalid (need 32 bytes base64); ZAP secrets-server disabled")
|
|
return nil
|
|
}
|
|
log.Printf("kms: WARNING: using legacy KMS_MASTER_KEY_B64 env-var REK; set MPC_REK_ENDPOINT to migrate to MPC-rooted REK")
|
|
return mk
|
|
}
|
|
|
|
// masterKeyFromEnv returns a 32-byte encryption key for ZapDB at-rest encryption,
|
|
// or nil to disable (dev only).
|
|
func masterKeyFromEnv() []byte {
|
|
b64 := os.Getenv("KMS_ENCRYPTION_KEY_B64")
|
|
if b64 == "" {
|
|
return nil
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil || len(key) != 32 {
|
|
log.Printf("kms: KMS_ENCRYPTION_KEY_B64 invalid (need 32 bytes base64); at-rest encryption disabled")
|
|
return nil
|
|
}
|
|
return key
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
|
switch v {
|
|
case "":
|
|
return fallback
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
// firstNonEmpty returns the first argument with non-zero length, or "" if
|
|
// all are empty. Used to fall back across the AWS / REPLICATE_S3 env-name
|
|
// variants without picking a default that could silently mask a typo.
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// normalizeS3Endpoint strips scheme + path from a REPLICATE_S3_ENDPOINT
|
|
// value so s3.New receives the bare host:port it expects. Returns
|
|
// (host[:port], useSSL) where useSSL is true unless the operator
|
|
// explicitly specified an http:// scheme. A non-empty path is logged as
|
|
// a warning so the misconfiguration is visible — the SDK rejects path-
|
|
// bearing endpoints with "Endpoint url cannot have fully qualified
|
|
// paths." and the historical behaviour was to silently disable
|
|
// replication.
|
|
func normalizeS3Endpoint(raw string) (host string, useSSL bool) {
|
|
useSSL = !strings.HasPrefix(raw, "http://")
|
|
|
|
// strings.HasPrefix above already covers "http://"; treat the bare
|
|
// "https://" case explicitly so the parse path stays simple.
|
|
if strings.Contains(raw, "://") {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
log.Printf("kms: REPLICATE_S3_ENDPOINT %q failed to parse — using as-is, replication may fail", raw)
|
|
return raw, useSSL
|
|
}
|
|
if u.Path != "" && u.Path != "/" {
|
|
log.Printf("kms: REPLICATE_S3_ENDPOINT %q has a path component (%q) — stripping; put the bucket in REPLICATE_S3_BUCKET", raw, u.Path)
|
|
}
|
|
if u.RawQuery != "" {
|
|
log.Printf("kms: REPLICATE_S3_ENDPOINT %q has a query string — stripping", raw)
|
|
}
|
|
return u.Host, useSSL
|
|
}
|
|
// Already bare host:port. Trim a trailing "/" if any operator added one.
|
|
return strings.TrimRight(raw, "/"), useSSL
|
|
}
|
|
|
|
// zapdbLogger adapts slog to ZapDB's Logger interface.
|
|
type zapdbLogger struct{}
|
|
|
|
func (zapdbLogger) Errorf(format string, args ...interface{}) {
|
|
slog.Error(fmt.Sprintf(format, args...))
|
|
}
|
|
func (zapdbLogger) Warningf(format string, args ...interface{}) {
|
|
slog.Warn(fmt.Sprintf(format, args...))
|
|
}
|
|
func (zapdbLogger) Infof(format string, args ...interface{}) {
|
|
slog.Info(fmt.Sprintf(format, args...))
|
|
}
|
|
func (zapdbLogger) Debugf(format string, args ...interface{}) {
|
|
slog.Debug(fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// registerWebUI mounts the embedded Vite SPA at `/` with a SPA fallback.
|
|
// API routes are registered earlier on the same mux and win the route
|
|
// match (Go 1.22 ServeMux specificity rules); this only catches the
|
|
// remainder. If `web/` is empty (not built yet), every fallthrough returns
|
|
// 404 — the API still works, just no UI.
|
|
func registerWebUI(mux *http.ServeMux) {
|
|
sub, err := fs.Sub(frontendDist, "web")
|
|
if err != nil {
|
|
log.Printf("kms: web UI disabled — embed sub: %v", err)
|
|
return
|
|
}
|
|
// If index.html is missing, treat the UI as not-built and return early.
|
|
// (`make build` populates `web/` from frontend/dist before `go build`.)
|
|
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
|
log.Printf("kms: web UI not built — `make build` populates cmd/kms/web from frontend/dist")
|
|
return
|
|
}
|
|
fileSrv := http.FileServer(http.FS(sub))
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
// Trim leading "/" once for fs.Stat.
|
|
clean := strings.TrimPrefix(r.URL.Path, "/")
|
|
if clean == "" {
|
|
clean = "index.html"
|
|
}
|
|
if _, err := fs.Stat(sub, clean); err != nil {
|
|
// Not a real asset — serve index.html so React Router takes over.
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = "/"
|
|
fileSrv.ServeHTTP(w, r2)
|
|
return
|
|
}
|
|
fileSrv.ServeHTTP(w, r)
|
|
})
|
|
log.Printf("kms: web UI mounted at /")
|
|
}
|