Files
zeekay 8ad4a45e8e refactor: rebrand Infisical CLI → Hanzo KMS CLI
- Binary name: kms (not infisical)
- Go module: github.com/hanzokms/cli
- Default API: https://kms.hanzo.ai
- Config dir: ~/.kms, workspace: .kms.json
- Copyright: Hanzo AI Inc.
- Keyring service: hanzo-kms
- npm package: @hanzokms/cli
- GoReleaser, Docker, scripts all updated
- External deps (go-sdk, kmip) keep original import paths
  until forked to hanzokms org
2026-02-24 02:08:49 -08:00

586 lines
18 KiB
Go

package cmd
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/hanzokms/cli/packages/proxy"
"github.com/hanzokms/cli/packages/util"
"github.com/hanzokms/cli/packages/util/cache"
"github.com/awnumar/memguard"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
type CacheEvictionStrategy string
const (
CacheEvictionStrategyOptimistic CacheEvictionStrategy = "optimistic"
)
var proxyCmd = &cobra.Command{
Example: `kms proxy start`,
Short: "Used to run Hanzo KMS proxy server",
Use: "proxy",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
}
var proxyStartCmd = &cobra.Command{
Example: `kms proxy start --domain=https://kms.hanzo.ai --listen-address=localhost:8081`,
Short: "Start the Hanzo KMS proxy server",
Use: "start",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: startProxyServer,
}
var proxyDebugCmd = &cobra.Command{
Example: `kms proxy debug --listen-address=localhost:8081`,
Short: "Print cache debug information (dev mode only)",
Use: "debug",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: printCacheDebug,
Hidden: true,
}
func startProxyServer(cmd *cobra.Command, args []string) {
domain, err := cmd.Flags().GetString("domain")
if err != nil {
util.HandleError(err, "Unable to parse domain flag")
}
if domain == "" {
util.PrintErrorMessageAndExit("Domain flag is required")
}
listenAddress, err := cmd.Flags().GetString("listen-address")
if err != nil {
util.HandleError(err, "Unable to parse listen-address flag")
}
tlsEnabled, err := cmd.Flags().GetBool("tls-enabled")
if err != nil {
util.HandleError(err, "Unable to parse tls-enabled flag")
}
tlsCertFile, err := cmd.Flags().GetString("tls-cert-file")
if err != nil {
util.HandleError(err, "Unable to parse tls-cert-file flag")
}
tlsKeyFile, err := cmd.Flags().GetString("tls-key-file")
if err != nil {
util.HandleError(err, "Unable to parse tls-key-file flag")
}
if tlsEnabled && (tlsCertFile == "" || tlsKeyFile == "") {
util.PrintErrorMessageAndExit("`tls-cert-file` and `tls-key-file` are required when `tls-enabled` is set to true")
}
if listenAddress == "" {
util.PrintErrorMessageAndExit("Listen-address flag is required")
}
evictionStrategy, err := cmd.Flags().GetString("eviction-strategy")
if err != nil {
util.HandleError(err, "Unable to parse eviction-strategy flag")
}
if evictionStrategy != string(CacheEvictionStrategyOptimistic) {
util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid eviction-strategy '%s'. Currently only 'optimistic' is supported.", evictionStrategy))
}
accessTokenCheckIntervalStr, err := cmd.Flags().GetString("access-token-check-interval")
if err != nil {
util.HandleError(err, "Unable to parse access-token-check-interval flag")
}
accessTokenCheckInterval, err := util.ParseTimeDurationString(accessTokenCheckIntervalStr, true)
if err != nil {
util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid access-token-check-interval format '%s'. Use formats like 5m, 1h, 1d", accessTokenCheckIntervalStr))
}
staticSecretsRefreshIntervalStr, err := cmd.Flags().GetString("static-secrets-refresh-interval")
if err != nil {
util.HandleError(err, "Unable to parse static-secrets-refresh-interval flag")
}
staticSecretsRefreshInterval, err := util.ParseTimeDurationString(staticSecretsRefreshIntervalStr, true)
if err != nil {
util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid static-secrets-refresh-interval format '%s'. Use formats like 30m, 1h, 1d", staticSecretsRefreshIntervalStr))
}
domainURL, err := url.Parse(domain)
if err != nil {
util.HandleError(err, fmt.Sprintf("Invalid domain URL: %s", domain))
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
// Create a separate client for streaming endpoints (no timeout for long-lived connections)
streamingClient := &http.Client{
Timeout: 0,
}
// Create in-memory cache (no persistence, no encryption needed for ephemeral data)
// For persistent cache with encryption, use proxy.NewCacheWithOptions
encryptionKey := memguard.NewBufferRandom(32)
defer encryptionKey.Destroy()
cache, err := proxy.NewCache(cache.EncryptedStorageOptions{
InMemory: true,
EncryptionKey: encryptionKey,
})
if err != nil {
util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to create cache: %v", err))
}
defer cache.Close()
mux := http.NewServeMux()
// Debug endpoint (dev mode only)
if util.IsDevelopmentMode() {
mux.HandleFunc("/_debug/cache", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
debugInfo := cache.GetDebugInfo()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(debugInfo); err != nil {
log.Error().Err(err).Msg("Failed to encode cache debug info")
http.Error(w, "Failed to encode debug info", http.StatusInternalServerError)
return
}
})
log.Info().Msg("Dev mode enabled: debug endpoint available at /_debug/cache")
}
proxyHandler := func(w http.ResponseWriter, r *http.Request) {
// Skip debug endpoints - they're handled by mux
if strings.HasPrefix(r.URL.Path, "/_debug/") {
http.NotFound(w, r)
return
}
token := proxy.ExtractTokenFromRequest(r)
isCacheable := proxy.IsCacheableRequest(r.URL.Path, r.Method)
isStreaming := isStreamingEndpoint(r.URL.Path)
// -- Cache Check --
if isCacheable && token != "" {
cacheKey := proxy.GenerateCacheKey(r.Method, r.URL.Path, r.URL.RawQuery, token)
if cachedResp, found := cache.Get(cacheKey); found {
log.Info().
Str("hash", cacheKey).
Msg("Cache hit")
proxy.CopyHeaders(w.Header(), cachedResp.Header)
w.WriteHeader(cachedResp.StatusCode)
_, err := io.Copy(w, cachedResp.Body)
if err != nil {
log.Error().Err(err).Msg("Failed to copy cached response body")
return
}
return
}
log.Info().
Str("hash", cacheKey).
Msg("Cache miss")
}
// -- Proxy Request --
// Read request body for mutation eviction (PATCH/DELETE) or restore for forwarding
var requestBodyBytes []byte
if r.Body != nil {
requestBodyBytes, err = io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Msg("Failed to read request body")
http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusInternalServerError)
return
}
}
targetURL := *domainURL
targetURL.Path = domainURL.Path + r.URL.Path
targetURL.RawQuery = r.URL.RawQuery
var bodyReader io.Reader
if requestBodyBytes != nil {
bodyReader = bytes.NewReader(requestBodyBytes)
}
proxyReq, err := http.NewRequest(r.Method, targetURL.String(), bodyReader)
if err != nil {
log.Error().Err(err).Msg("Failed to create proxy request")
http.Error(w, fmt.Sprintf("Failed to create proxy request: %v", err), http.StatusInternalServerError)
return
}
proxy.CopyHeaders(proxyReq.Header, r.Header)
log.Debug().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("target", targetURL.String()).
Msg("Forwarding request")
// Use streaming client for SSE/streaming endpoints, regular client for others
clientToUse := httpClient
if isStreaming {
clientToUse = streamingClient
}
resp, err := clientToUse.Do(proxyReq)
if err != nil {
log.Error().Err(err).Msg("Failed to forward request")
http.Error(w, fmt.Sprintf("Failed to forward request: %v", err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// -- Proxy Response --
proxy.CopyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
// For streaming endpoints, stream directly instead of buffering
if isStreaming {
// Flush headers immediately for SSE
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// Stream with periodic flushing for SSE events
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
log.Error().Err(writeErr).Msg("Failed to write streaming response")
return
}
// Flush after each write to send SSE events immediately
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
break
}
if err != nil {
log.Error().Err(err).Msg("Failed to read streaming response")
return
}
}
return
}
// For non-streaming endpoints, read into memory for caching and serving
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Failed to read response body")
http.Error(w, fmt.Sprintf("Failed to read response body: %v", err), http.StatusInternalServerError)
return
}
_, err = w.Write(bodyBytes)
if err != nil {
log.Error().Err(err).Msg("Failed to write response body")
return
}
// -- Secret Mutation Purging --
if (r.Method == http.MethodPatch || r.Method == http.MethodDelete || r.Method == http.MethodPost) &&
proxy.IsSecretsEndpoint(r.URL.Path) &&
resp.StatusCode >= 200 && resp.StatusCode < 300 {
var projectId, environment, secretPath string
if len(requestBodyBytes) > 0 {
var bodyData map[string]interface{}
if err := json.Unmarshal(requestBodyBytes, &bodyData); err == nil {
// Support both v3 (workspaceId/workspaceSlug) and v4 (projectId)
if projId, ok := bodyData["projectId"].(string); ok {
projectId = projId
} else if workspaceId, ok := bodyData["workspaceId"].(string); ok {
projectId = workspaceId
} else if workspaceSlug, ok := bodyData["workspaceSlug"].(string); ok {
projectId = workspaceSlug
}
if env, ok := bodyData["environment"].(string); ok {
environment = env
}
if path, ok := bodyData["secretPath"].(string); ok {
secretPath = path
}
} else {
log.Error().
Err(err).
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("Failed to parse mutation request body for cache purging - cache may serve stale data")
}
}
if secretPath == "" {
secretPath = "/"
}
if projectId == "" || environment == "" {
log.Warn().
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("Missing projectId or environment for cache purging - skipping cache purge")
return
} else {
log.Debug().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("projectId", projectId).
Str("environment", environment).
Str("secretPath", secretPath).
Msg("Attempting mutation purging across all tokens")
purgedCount := cache.PurgeByMutation(projectId, environment, secretPath)
if purgedCount == 1 {
log.Info().
Str("mutationPath", secretPath).
Msg("Entry purged")
} else {
log.Info().
Int("purgedCount", purgedCount).
Str("mutationPath", secretPath).
Msg("Entries purged")
}
}
}
// -- Cache Set --
if isCacheable && token != "" && resp.StatusCode == http.StatusOK {
cacheKey := proxy.GenerateCacheKey(r.Method, r.URL.Path, r.URL.RawQuery, token)
queryParams := r.URL.Query()
// Support both v3 (workspaceId/workspaceSlug) and v4 (projectId)
projectId := queryParams.Get("projectId")
if projectId == "" {
projectId = queryParams.Get("workspaceId")
}
if projectId == "" {
projectId = queryParams.Get("workspaceSlug")
}
environment := queryParams.Get("environment")
secretPath := queryParams.Get("secretPath")
if secretPath == "" {
secretPath = "/"
}
if r.URL.Path == "/api/v3/secrets" || r.URL.Path == "/api/v4/secrets" ||
r.URL.Path == "/api/v3/secrets/raw" || r.URL.Path == "/api/v4/secrets/raw" {
recursive := queryParams.Get("recursive")
if recursive == "true" {
secretPath = secretPath + "*"
}
}
indexEntry := proxy.IndexEntry{
CacheKey: cacheKey,
SecretPath: secretPath,
EnvironmentSlug: environment,
ProjectId: projectId,
}
cachedResp := &http.Response{
StatusCode: resp.StatusCode,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
}
proxy.CopyHeaders(cachedResp.Header, resp.Header)
if indexEntry.ProjectId != "" && indexEntry.EnvironmentSlug != "" {
cache.Set(cacheKey, r, cachedResp, token, indexEntry)
log.Debug().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("cacheKey", cacheKey).
Msg("Secret response cached successfully")
} else {
log.Warn().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("cacheKey", cacheKey).
Msg("Secret response not cached because project ID or environment slug is empty")
}
}
log.Debug().
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", resp.StatusCode).
Msg("Request forwarded successfully")
}
// Add proxy handler to mux
mux.HandleFunc("/", proxyHandler)
var tlsConfig *tls.Config
if tlsEnabled {
cert, err := tls.LoadX509KeyPair(tlsCertFile, tlsKeyFile)
if err != nil {
util.HandleError(err, fmt.Sprintf("Failed to load TLS certificate and key: %s", err))
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
}
server := &http.Server{
Addr: listenAddress,
Handler: mux,
TLSConfig: tlsConfig,
}
resyncCtx, resyncCancel := context.WithCancel(context.Background())
defer resyncCancel()
go proxy.StartBackgroundLoops(resyncCtx, cache, domainURL, httpClient, evictionStrategy, accessTokenCheckInterval, staticSecretsRefreshInterval)
// Handle graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Info().Msgf("Received signal %v, shutting down proxy server...", sig)
// Cancel resync goroutine
resyncCancel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Error().Err(err).Msg("Error during server shutdown")
os.Exit(1)
}
log.Info().Msg("Proxy server shutdown complete")
os.Exit(0)
}()
if tlsEnabled {
log.Info().Msgf("Hanzo KMS proxy server starting on %s with TLS enabled", listenAddress)
} else {
log.Info().Msgf("Hanzo KMS proxy server starting on %s", listenAddress)
}
log.Info().Msgf("Forwarding requests to %s", domain)
if tlsEnabled {
if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
util.HandleError(err, "Failed to start proxy server with TLS")
}
} else {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
util.HandleError(err, "Failed to start proxy server")
}
}
}
func printCacheDebug(cmd *cobra.Command, args []string) {
if util.CLI_VERSION != "devel" {
util.PrintErrorMessageAndExit("This command is only available in dev mode (when CLI_VERSION is 'devel').")
}
listenAddress, err := cmd.Flags().GetString("listen-address")
if err != nil {
util.HandleError(err, "Unable to parse listen-address flag")
}
if listenAddress == "" {
util.PrintErrorMessageAndExit("Listen-address flag is required")
}
baseURL := "http://" + listenAddress
if strings.HasPrefix(listenAddress, ":") {
baseURL = "http://localhost" + listenAddress
}
debugURL := baseURL + "/_debug/cache"
resp, err := http.Get(debugURL)
if err != nil {
util.HandleError(err, fmt.Sprintf("Failed to connect to proxy at %s. Make sure the proxy is running in dev mode (CLI_VERSION='devel')", listenAddress))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to get cache debug info: %s", string(body)))
}
var debugInfo proxy.CacheDebugInfo
if err := json.NewDecoder(resp.Body).Decode(&debugInfo); err != nil {
util.HandleError(err, "Failed to decode cache debug info")
}
output, err := json.MarshalIndent(debugInfo, "", " ")
if err != nil {
util.HandleError(err, "Failed to marshal cache debug info")
}
fmt.Println("Cache Debug Information:")
fmt.Println(string(output))
}
func isStreamingEndpoint(path string) bool {
return strings.HasPrefix(path, "/api/v1/events/")
}
func init() {
proxyStartCmd.Flags().String("domain", "", "Domain of your Hanzo KMS instance (e.g., https://kms.hanzo.ai for cloud, https://my-self-hosted-instance.com for self-hosted)")
proxyStartCmd.Flags().String("listen-address", "localhost:8081", "The address for the proxy server to listen on. Defaults to localhost:8081")
proxyStartCmd.Flags().String("eviction-strategy", string(CacheEvictionStrategyOptimistic), "Cache eviction strategy. 'optimistic' keeps cached data when KMS is unreachable for high availability. Currently only 'optimistic' is supported.")
proxyStartCmd.Flags().String("access-token-check-interval", "5m", "How often to validate that access tokens are still valid (e.g., 5m, 1h). Defaults to 5m.")
proxyStartCmd.Flags().String("static-secrets-refresh-interval", "1h", "How often to refresh cached secrets (e.g., 30m, 1h, 1d). Defaults to 1h.")
proxyStartCmd.Flags().String("tls-cert-file", "", "The path to the TLS certificate file for the proxy server. Required when `tls-enabled` is set to true (default)")
proxyStartCmd.Flags().String("tls-key-file", "", "The path to the TLS key file for the proxy server. Required when `tls-enabled` is set to true (default)")
proxyStartCmd.Flags().Bool("tls-enabled", true, "Whether to enable TLS for the proxy server. Defaults to true")
proxyDebugCmd.Flags().String("listen-address", "localhost:8081", "The address where the proxy server is listening. Defaults to localhost:8081")
proxyCmd.AddCommand(proxyStartCmd)
proxyCmd.AddCommand(proxyDebugCmd)
RootCmd.AddCommand(proxyCmd)
}