A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

clean up old http api in agent. move everything to grpc, try and fix requests to listsecrets over grpc

+84 -1020
+2 -2
helm/hsm-secrets-operator/Chart.yaml
··· 2 2 name: hsm-secrets-operator 3 3 description: A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets 4 4 type: application 5 - version: 0.5.5 6 - appVersion: v0.5.5 5 + version: 0.5.6 6 + appVersion: v0.5.6 7 7 icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg 8 8 home: https://github.com/evanjarrett/hsm-secrets-operator 9 9 sources:
-419
internal/agent/client.go
··· 1 - /* 2 - Copyright 2025. 3 - 4 - Licensed under the Apache License, Version 2.0 (the "License"); 5 - you may not use this file except in compliance with the License. 6 - You may obtain a copy of the License at 7 - 8 - http://www.apache.org/licenses/LICENSE-2.0 9 - 10 - Unless required by applicable law or agreed to in writing, software 11 - distributed under the License is distributed on an "AS IS" BASIS, 12 - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 - See the License for the specific language governing permissions and 14 - limitations under the License. 15 - */ 16 - 17 - package agent 18 - 19 - import ( 20 - "bytes" 21 - "context" 22 - "encoding/json" 23 - "fmt" 24 - "io" 25 - "net/http" 26 - "strings" 27 - "time" 28 - 29 - "github.com/go-logr/logr" 30 - 31 - "github.com/evanjarrett/hsm-secrets-operator/internal/hsm" 32 - ) 33 - 34 - // Client implements the HSM client interface by communicating with HSM agents 35 - type Client struct { 36 - httpClient *http.Client 37 - baseURL string 38 - logger logr.Logger 39 - deviceName string 40 - timeout time.Duration 41 - retryAttempts int 42 - retryDelay time.Duration 43 - } 44 - 45 - // NewClient creates a new agent client 46 - func NewClient(baseURL, deviceName string, logger logr.Logger) *Client { 47 - return &Client{ 48 - httpClient: &http.Client{ 49 - Timeout: 30 * time.Second, 50 - }, 51 - baseURL: strings.TrimSuffix(baseURL, "/"), 52 - logger: logger.WithName("agent-client"), 53 - deviceName: deviceName, 54 - timeout: 30 * time.Second, 55 - retryAttempts: 3, 56 - retryDelay: 2 * time.Second, 57 - } 58 - } 59 - 60 - // Initialize establishes connection to the HSM agent 61 - func (c *Client) Initialize(ctx context.Context, config hsm.Config) error { 62 - // The agent handles HSM initialization, we just need to verify connectivity 63 - info, err := c.GetInfo(ctx) 64 - if err != nil { 65 - return fmt.Errorf("failed to initialize agent client: %w", err) 66 - } 67 - 68 - c.logger.Info("Agent client initialized", "device", c.deviceName, "hsm_label", info.Label) 69 - return nil 70 - } 71 - 72 - // Close terminates the HSM connection 73 - func (c *Client) Close() error { 74 - // HTTP client doesn't need explicit closing, agent handles HSM cleanup 75 - return nil 76 - } 77 - 78 - // GetInfo returns information about the HSM device 79 - func (c *Client) GetInfo(ctx context.Context) (*hsm.HSMInfo, error) { 80 - var response AgentResponse 81 - if err := c.doRequest(ctx, "GET", "/api/v1/hsm/info", nil, &response); err != nil { 82 - return nil, fmt.Errorf("failed to get HSM info: %w", err) 83 - } 84 - 85 - if !response.Success { 86 - return nil, fmt.Errorf("agent error: %s", response.Error.Message) 87 - } 88 - 89 - // Convert response data to HSMInfo 90 - data, ok := response.Data.(map[string]any) 91 - if !ok { 92 - return nil, fmt.Errorf("invalid response data format") 93 - } 94 - 95 - info := &hsm.HSMInfo{} 96 - if label, ok := data["label"].(string); ok { 97 - info.Label = label 98 - } 99 - if manufacturer, ok := data["manufacturer"].(string); ok { 100 - info.Manufacturer = manufacturer 101 - } 102 - if model, ok := data["model"].(string); ok { 103 - info.Model = model 104 - } 105 - if serialNumber, ok := data["serialNumber"].(string); ok { 106 - info.SerialNumber = serialNumber 107 - } 108 - if firmwareVersion, ok := data["firmwareVersion"].(string); ok { 109 - info.FirmwareVersion = firmwareVersion 110 - } 111 - 112 - return info, nil 113 - } 114 - 115 - // ReadSecret reads secret data from the specified HSM path 116 - func (c *Client) ReadSecret(ctx context.Context, path string) (hsm.SecretData, error) { 117 - escapedPath := c.escapePath(path) 118 - endpoint := fmt.Sprintf("/api/v1/hsm/secrets/%s", escapedPath) 119 - 120 - var response AgentResponse 121 - if err := c.doRequest(ctx, "GET", endpoint, nil, &response); err != nil { 122 - return nil, fmt.Errorf("failed to read secret: %w", err) 123 - } 124 - 125 - if !response.Success { 126 - return nil, fmt.Errorf("agent error: %s", response.Error.Message) 127 - } 128 - 129 - // Convert response data to SecretData 130 - responseData, ok := response.Data.(map[string]any) 131 - if !ok { 132 - return nil, fmt.Errorf("invalid response data format") 133 - } 134 - 135 - secretDataRaw, ok := responseData["data"].(map[string]any) 136 - if !ok { 137 - return nil, fmt.Errorf("invalid secret data format") 138 - } 139 - 140 - secretData := make(hsm.SecretData) 141 - for key, value := range secretDataRaw { 142 - switch v := value.(type) { 143 - case string: 144 - secretData[key] = []byte(v) 145 - case []byte: 146 - secretData[key] = v 147 - case []any: 148 - // Handle JSON array (byte array) 149 - byteArray := make([]byte, len(v)) 150 - for i, b := range v { 151 - if byteVal, ok := b.(float64); ok { 152 - byteArray[i] = byte(byteVal) 153 - } 154 - } 155 - secretData[key] = byteArray 156 - default: 157 - // Convert to string as fallback 158 - secretData[key] = []byte(fmt.Sprintf("%v", v)) 159 - } 160 - } 161 - 162 - return secretData, nil 163 - } 164 - 165 - // WriteSecret writes secret data to the specified HSM path 166 - func (c *Client) WriteSecret(ctx context.Context, path string, data hsm.SecretData) error { 167 - escapedPath := c.escapePath(path) 168 - endpoint := fmt.Sprintf("/api/v1/hsm/secrets/%s", escapedPath) 169 - 170 - // Convert SecretData to request format 171 - requestData := make(map[string]any) 172 - for key, value := range data { 173 - requestData[key] = string(value) 174 - } 175 - 176 - request := AgentRequest{ 177 - Path: path, 178 - Data: requestData, 179 - } 180 - 181 - var response AgentResponse 182 - if err := c.doRequest(ctx, "POST", endpoint, &request, &response); err != nil { 183 - return fmt.Errorf("failed to write secret: %w", err) 184 - } 185 - 186 - if !response.Success { 187 - return fmt.Errorf("agent error: %s", response.Error.Message) 188 - } 189 - 190 - return nil 191 - } 192 - 193 - // DeleteSecret removes secret data from the specified HSM path 194 - func (c *Client) DeleteSecret(ctx context.Context, path string) error { 195 - escapedPath := c.escapePath(path) 196 - endpoint := fmt.Sprintf("/api/v1/hsm/secrets/%s", escapedPath) 197 - 198 - var response AgentResponse 199 - if err := c.doRequest(ctx, "DELETE", endpoint, nil, &response); err != nil { 200 - return fmt.Errorf("failed to delete secret: %w", err) 201 - } 202 - 203 - if !response.Success { 204 - return fmt.Errorf("agent error: %s", response.Error.Message) 205 - } 206 - 207 - return nil 208 - } 209 - 210 - // ListSecrets returns a list of secret paths 211 - func (c *Client) ListSecrets(ctx context.Context, prefix string) ([]string, error) { 212 - endpoint := "/api/v1/hsm/secrets" 213 - if prefix != "" { 214 - endpoint += "?prefix=" + prefix 215 - } 216 - 217 - var response AgentResponse 218 - if err := c.doRequest(ctx, "GET", endpoint, nil, &response); err != nil { 219 - return nil, fmt.Errorf("failed to list secrets: %w", err) 220 - } 221 - 222 - if !response.Success { 223 - return nil, fmt.Errorf("agent error: %s", response.Error.Message) 224 - } 225 - 226 - // Convert response data to string slice 227 - responseData, ok := response.Data.(map[string]any) 228 - if !ok { 229 - return nil, fmt.Errorf("invalid response data format") 230 - } 231 - 232 - pathsRaw, ok := responseData["paths"].([]any) 233 - if !ok { 234 - return nil, fmt.Errorf("invalid paths data format") 235 - } 236 - 237 - paths := make([]string, len(pathsRaw)) 238 - for i, pathRaw := range pathsRaw { 239 - if path, ok := pathRaw.(string); ok { 240 - paths[i] = path 241 - } 242 - } 243 - 244 - return paths, nil 245 - } 246 - 247 - // GetChecksum returns the SHA256 checksum of the secret data at the given path 248 - func (c *Client) GetChecksum(ctx context.Context, path string) (string, error) { 249 - escapedPath := c.escapePath(path) 250 - endpoint := fmt.Sprintf("/api/v1/hsm/checksum/%s", escapedPath) 251 - 252 - var response AgentResponse 253 - if err := c.doRequest(ctx, "GET", endpoint, nil, &response); err != nil { 254 - return "", fmt.Errorf("failed to get checksum: %w", err) 255 - } 256 - 257 - if !response.Success { 258 - return "", fmt.Errorf("agent error: %s", response.Error.Message) 259 - } 260 - 261 - // Extract checksum from response 262 - responseData, ok := response.Data.(map[string]any) 263 - if !ok { 264 - return "", fmt.Errorf("invalid response data format") 265 - } 266 - 267 - checksum, ok := responseData["checksum"].(string) 268 - if !ok { 269 - return "", fmt.Errorf("invalid checksum format") 270 - } 271 - 272 - return checksum, nil 273 - } 274 - 275 - // IsConnected returns true if the HSM agent is connected and responsive 276 - func (c *Client) IsConnected() bool { 277 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 278 - defer cancel() 279 - 280 - _, err := c.GetInfo(ctx) 281 - return err == nil 282 - } 283 - 284 - // doRequest performs an HTTP request with retry logic 285 - func (c *Client) doRequest(ctx context.Context, method, endpoint string, requestBody any, responseBody any) error { 286 - url := c.baseURL + endpoint 287 - 288 - var reqBodyReader io.Reader 289 - if requestBody != nil { 290 - jsonBody, err := json.Marshal(requestBody) 291 - if err != nil { 292 - return fmt.Errorf("failed to marshal request body: %w", err) 293 - } 294 - reqBodyReader = bytes.NewReader(jsonBody) 295 - } 296 - 297 - var lastErr error 298 - for attempt := 0; attempt <= c.retryAttempts; attempt++ { 299 - if attempt > 0 { 300 - c.logger.V(1).Info("Retrying request", "attempt", attempt, "url", url, "method", method) 301 - 302 - // Reset the request body reader for retry 303 - if requestBody != nil { 304 - jsonBody, _ := json.Marshal(requestBody) 305 - reqBodyReader = bytes.NewReader(jsonBody) 306 - } 307 - 308 - select { 309 - case <-ctx.Done(): 310 - return ctx.Err() 311 - case <-time.After(c.retryDelay): 312 - // Continue with retry 313 - } 314 - } 315 - 316 - req, err := http.NewRequestWithContext(ctx, method, url, reqBodyReader) 317 - if err != nil { 318 - lastErr = fmt.Errorf("failed to create request: %w", err) 319 - continue 320 - } 321 - 322 - if requestBody != nil { 323 - req.Header.Set("Content-Type", "application/json") 324 - } 325 - 326 - resp, err := c.httpClient.Do(req) 327 - if err != nil { 328 - lastErr = fmt.Errorf("request failed: %w", err) 329 - continue 330 - } 331 - 332 - defer func() { 333 - if err := resp.Body.Close(); err != nil { 334 - c.logger.V(1).Info("Failed to close response body", "error", err) 335 - } 336 - }() 337 - 338 - // Read response body 339 - bodyBytes, err := io.ReadAll(resp.Body) 340 - if err != nil { 341 - lastErr = fmt.Errorf("failed to read response body: %w", err) 342 - continue 343 - } 344 - 345 - // Check for HTTP errors 346 - if resp.StatusCode >= 400 { 347 - // Try to parse error response 348 - var errorResp AgentResponse 349 - if json.Unmarshal(bodyBytes, &errorResp) == nil && errorResp.Error != nil { 350 - lastErr = fmt.Errorf("agent error (status %d): %s", resp.StatusCode, errorResp.Error.Message) 351 - } else { 352 - lastErr = fmt.Errorf("HTTP error (status %d): %s", resp.StatusCode, string(bodyBytes)) 353 - } 354 - 355 - // Don't retry client errors (4xx) 356 - if resp.StatusCode >= 400 && resp.StatusCode < 500 { 357 - break 358 - } 359 - continue 360 - } 361 - 362 - // Parse successful response 363 - if responseBody != nil { 364 - if err := json.Unmarshal(bodyBytes, responseBody); err != nil { 365 - lastErr = fmt.Errorf("failed to unmarshal response: %w", err) 366 - continue 367 - } 368 - } 369 - 370 - // Success 371 - return nil 372 - } 373 - 374 - return fmt.Errorf("request failed after %d attempts: %w", c.retryAttempts+1, lastErr) 375 - } 376 - 377 - // escapePath escapes path components for URL usage 378 - func (c *Client) escapePath(path string) string { 379 - // Simple path escaping - in production might want more sophisticated handling 380 - path = strings.ReplaceAll(path, "/", "%2F") 381 - path = strings.ReplaceAll(path, " ", "%20") 382 - return path 383 - } 384 - 385 - // SetRetryPolicy configures retry behavior 386 - func (c *Client) SetRetryPolicy(attempts int, delay time.Duration) { 387 - c.retryAttempts = attempts 388 - c.retryDelay = delay 389 - } 390 - 391 - // SetTimeout configures request timeout 392 - func (c *Client) SetTimeout(timeout time.Duration) { 393 - c.timeout = timeout 394 - c.httpClient.Timeout = timeout 395 - } 396 - 397 - // WriteSecretWithMetadata writes secret data and metadata to the specified HSM path 398 - func (c *Client) WriteSecretWithMetadata(ctx context.Context, path string, data hsm.SecretData, metadata *hsm.SecretMetadata) error { 399 - // For now, just write the secret data - metadata support can be added to agent API later 400 - // This provides compatibility with the updated interface 401 - if err := c.WriteSecret(ctx, path, data); err != nil { 402 - return err 403 - } 404 - 405 - // TODO: Add metadata storage to agent API endpoints 406 - if metadata != nil { 407 - c.logger.V(1).Info("Metadata not yet supported in agent API, skipping", "path", path) 408 - } 409 - 410 - return nil 411 - } 412 - 413 - // ReadMetadata reads metadata for a secret at the given path 414 - func (c *Client) ReadMetadata(ctx context.Context, path string) (*hsm.SecretMetadata, error) { 415 - // TODO: Add metadata reading from agent API endpoints 416 - // For now, return empty metadata to satisfy interface 417 - c.logger.V(1).Info("Metadata reading not yet supported in agent API", "path", path) 418 - return nil, fmt.Errorf("metadata not found for path: %s (agent API doesn't support metadata yet)", path) 419 - }
-18
internal/agent/deployment.go
··· 42 42 type ManagerInterface interface { 43 43 EnsureAgent(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice, hsmSecret *hsmv1alpha1.HSMSecret) (string, error) 44 44 CleanupAgent(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) error 45 - GetAgentEndpoint(hsmDevice *hsmv1alpha1.HSMDevice) string 46 45 } 47 46 48 47 // AgentStatus represents the current status of an agent ··· 275 274 // generateAgentName creates a consistent agent name for an HSM device 276 275 func (m *Manager) generateAgentName(hsmDevice *hsmv1alpha1.HSMDevice) string { 277 276 return fmt.Sprintf("%s-%s", AgentNamePrefix, hsmDevice.Name) 278 - } 279 - 280 - // getAgentEndpoint returns the HTTP endpoint for the agent 281 - // TODO: This will be removed when we switch to direct pod gRPC connections 282 - func (m *Manager) getAgentEndpoint(agentName, namespace string) string { 283 - return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", agentName, namespace, AgentPort) 284 - } 285 - 286 - // GetAgentEndpoint returns the HTTP endpoint for the agent for a given HSM device 287 - // This implements the ManagerInterface 288 - func (m *Manager) GetAgentEndpoint(hsmDevice *hsmv1alpha1.HSMDevice) string { 289 - agentName := m.generateAgentName(hsmDevice) 290 - namespace := hsmDevice.Namespace 291 - if namespace == "" { 292 - namespace = m.AgentNamespace 293 - } 294 - return m.getAgentEndpoint(agentName, namespace) 295 277 } 296 278 297 279 // createAgentDeployment creates the HSM agent deployment
-480
internal/agent/server.go
··· 1 - /* 2 - Copyright 2025. 3 - 4 - Licensed under the Apache License, Version 2.0 (the "License"); 5 - you may not use this file except in compliance with the License. 6 - You may obtain a copy of the License at 7 - 8 - http://www.apache.org/licenses/LICENSE-2.0 9 - 10 - Unless required by applicable law or agreed to in writing, software 11 - distributed under the License is distributed on an "AS IS" BASIS, 12 - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 - See the License for the specific language governing permissions and 14 - limitations under the License. 15 - */ 16 - 17 - package agent 18 - 19 - import ( 20 - "context" 21 - "fmt" 22 - "net/http" 23 - "time" 24 - 25 - "github.com/gin-gonic/gin" 26 - "github.com/go-logr/logr" 27 - "github.com/go-playground/validator/v10" 28 - 29 - "github.com/evanjarrett/hsm-secrets-operator/internal/hsm" 30 - ) 31 - 32 - // Server represents the HSM agent HTTP server 33 - type Server struct { 34 - hsmClient hsm.Client 35 - validator *validator.Validate 36 - logger logr.Logger 37 - router *gin.Engine 38 - deviceName string 39 - port int 40 - healthPort int 41 - } 42 - 43 - // AgentRequest represents a generic HSM operation request 44 - type AgentRequest struct { 45 - Path string `json:"path" validate:"required"` 46 - Data map[string]any `json:"data,omitempty"` 47 - } 48 - 49 - // AgentResponse represents a generic HSM operation response 50 - type AgentResponse struct { 51 - Success bool `json:"success"` 52 - Message string `json:"message,omitempty"` 53 - Data any `json:"data,omitempty"` 54 - Error *AgentError `json:"error,omitempty"` 55 - } 56 - 57 - // AgentError represents an error response 58 - type AgentError struct { 59 - Code string `json:"code"` 60 - Message string `json:"message"` 61 - Details map[string]any `json:"details,omitempty"` 62 - } 63 - 64 - // HealthStatus represents the health status of the agent 65 - type HealthStatus struct { 66 - Status string `json:"status"` 67 - DeviceName string `json:"deviceName"` 68 - HSMConnected bool `json:"hsmConnected"` 69 - Timestamp time.Time `json:"timestamp"` 70 - Uptime string `json:"uptime"` 71 - } 72 - 73 - // NewServer creates a new HSM agent server 74 - func NewServer(hsmClient hsm.Client, deviceName string, port, healthPort int, logger logr.Logger) *Server { 75 - s := &Server{ 76 - hsmClient: hsmClient, 77 - validator: validator.New(), 78 - logger: logger.WithName("agent-server"), 79 - deviceName: deviceName, 80 - port: port, 81 - healthPort: healthPort, 82 - } 83 - 84 - s.setupRouter() 85 - return s 86 - } 87 - 88 - // setupRouter configures the HTTP routes 89 - func (s *Server) setupRouter() { 90 - gin.SetMode(gin.ReleaseMode) 91 - s.router = gin.New() 92 - 93 - // Add middleware 94 - s.router.Use(gin.Recovery()) 95 - s.router.Use(s.loggingMiddleware()) 96 - s.router.Use(s.authMiddleware()) 97 - 98 - // API v1 routes 99 - v1 := s.router.Group("/api/v1") 100 - { 101 - // HSM operations 102 - hsmGroup := v1.Group("/hsm") 103 - { 104 - hsmGroup.GET("/info", s.handleGetInfo) 105 - hsmGroup.GET("/secrets/:path", s.handleReadSecret) 106 - hsmGroup.POST("/secrets/:path", s.handleWriteSecret) 107 - hsmGroup.PUT("/secrets/:path", s.handleWriteSecret) 108 - hsmGroup.DELETE("/secrets/:path", s.handleDeleteSecret) 109 - hsmGroup.GET("/secrets", s.handleListSecrets) 110 - hsmGroup.GET("/checksum/:path", s.handleGetChecksum) 111 - } 112 - } 113 - 114 - // Health endpoints (separate router for different port) 115 - s.setupHealthRouter() 116 - } 117 - 118 - // setupHealthRouter sets up health check routes 119 - func (s *Server) setupHealthRouter() { 120 - // Health checks will be handled by a separate handler function 121 - // This is called during Start() 122 - } 123 - 124 - // Start starts both the main API server and health server 125 - func (s *Server) Start(ctx context.Context) error { 126 - // Start health server in background 127 - healthMux := http.NewServeMux() 128 - healthMux.HandleFunc("/healthz", s.handleHealthz) 129 - healthMux.HandleFunc("/readyz", s.handleReadyz) 130 - 131 - healthServer := &http.Server{ 132 - Addr: fmt.Sprintf(":%d", s.healthPort), 133 - Handler: healthMux, 134 - } 135 - 136 - go func() { 137 - s.logger.Info("Starting health server", "port", s.healthPort) 138 - if err := healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 139 - s.logger.Error(err, "Health server failed") 140 - } 141 - }() 142 - 143 - // Start main API server 144 - server := &http.Server{ 145 - Addr: fmt.Sprintf(":%d", s.port), 146 - Handler: s.router, 147 - } 148 - 149 - // Graceful shutdown 150 - go func() { 151 - <-ctx.Done() 152 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 153 - defer cancel() 154 - 155 - s.logger.Info("Shutting down servers") 156 - if err := healthServer.Shutdown(shutdownCtx); err != nil { 157 - s.logger.Error(err, "Failed to shutdown health server") 158 - } 159 - if err := server.Shutdown(shutdownCtx); err != nil { 160 - s.logger.Error(err, "Failed to shutdown main server") 161 - } 162 - }() 163 - 164 - s.logger.Info("Starting HSM agent server", "port", s.port, "device", s.deviceName) 165 - return server.ListenAndServe() 166 - } 167 - 168 - // handleGetInfo handles HSM info requests 169 - func (s *Server) handleGetInfo(c *gin.Context) { 170 - ctx := c.Request.Context() 171 - 172 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 173 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 174 - return 175 - } 176 - 177 - info, err := s.hsmClient.GetInfo(ctx) 178 - if err != nil { 179 - s.logger.Error(err, "Failed to get HSM info") 180 - s.sendError(c, http.StatusInternalServerError, "hsm_error", "Failed to get HSM info", map[string]any{ 181 - "error": err.Error(), 182 - }) 183 - return 184 - } 185 - 186 - s.sendResponse(c, http.StatusOK, "HSM info retrieved", info) 187 - } 188 - 189 - // handleReadSecret handles secret read requests 190 - func (s *Server) handleReadSecret(c *gin.Context) { 191 - ctx := c.Request.Context() 192 - path := c.Param("path") 193 - 194 - if path == "" { 195 - s.sendError(c, http.StatusBadRequest, "invalid_path", "Path parameter is required", nil) 196 - return 197 - } 198 - 199 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 200 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 201 - return 202 - } 203 - 204 - data, err := s.hsmClient.ReadSecret(ctx, path) 205 - if err != nil { 206 - s.logger.Error(err, "Failed to read secret", "path", path) 207 - s.sendError(c, http.StatusInternalServerError, "read_error", "Failed to read secret", map[string]any{ 208 - "path": path, 209 - "error": err.Error(), 210 - }) 211 - return 212 - } 213 - 214 - // Calculate checksum 215 - checksum := hsm.CalculateChecksum(data) 216 - 217 - response := map[string]any{ 218 - "path": path, 219 - "data": data, 220 - "checksum": checksum, 221 - } 222 - 223 - s.sendResponse(c, http.StatusOK, "Secret read successfully", response) 224 - } 225 - 226 - // handleWriteSecret handles secret write requests 227 - func (s *Server) handleWriteSecret(c *gin.Context) { 228 - ctx := c.Request.Context() 229 - path := c.Param("path") 230 - 231 - if path == "" { 232 - s.sendError(c, http.StatusBadRequest, "invalid_path", "Path parameter is required", nil) 233 - return 234 - } 235 - 236 - var req AgentRequest 237 - if err := c.ShouldBindJSON(&req); err != nil { 238 - s.sendError(c, http.StatusBadRequest, "invalid_request", "Invalid JSON payload", map[string]any{ 239 - "error": err.Error(), 240 - }) 241 - return 242 - } 243 - 244 - // Use path from URL parameter, not request body 245 - req.Path = path 246 - 247 - if err := s.validator.Struct(&req); err != nil { 248 - s.sendError(c, http.StatusBadRequest, "validation_failed", "Request validation failed", map[string]any{ 249 - "error": err.Error(), 250 - }) 251 - return 252 - } 253 - 254 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 255 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 256 - return 257 - } 258 - 259 - // Convert request data to HSM format 260 - hsmData := make(hsm.SecretData) 261 - for key, value := range req.Data { 262 - switch v := value.(type) { 263 - case string: 264 - hsmData[key] = []byte(v) 265 - case []byte: 266 - hsmData[key] = v 267 - default: 268 - // Convert to string as fallback 269 - hsmData[key] = fmt.Appendf(nil, "%v", v) 270 - } 271 - } 272 - 273 - if err := s.hsmClient.WriteSecret(ctx, req.Path, hsmData); err != nil { 274 - s.logger.Error(err, "Failed to write secret", "path", req.Path) 275 - s.sendError(c, http.StatusInternalServerError, "write_error", "Failed to write secret", map[string]any{ 276 - "path": req.Path, 277 - "error": err.Error(), 278 - }) 279 - return 280 - } 281 - 282 - // Calculate checksum for response 283 - checksum := hsm.CalculateChecksum(hsmData) 284 - 285 - response := map[string]any{ 286 - "path": req.Path, 287 - "checksum": checksum, 288 - } 289 - 290 - s.sendResponse(c, http.StatusCreated, "Secret written successfully", response) 291 - } 292 - 293 - // handleDeleteSecret handles secret deletion requests 294 - func (s *Server) handleDeleteSecret(c *gin.Context) { 295 - ctx := c.Request.Context() 296 - path := c.Param("path") 297 - 298 - if path == "" { 299 - s.sendError(c, http.StatusBadRequest, "invalid_path", "Path parameter is required", nil) 300 - return 301 - } 302 - 303 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 304 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 305 - return 306 - } 307 - 308 - if err := s.hsmClient.DeleteSecret(ctx, path); err != nil { 309 - s.logger.Error(err, "Failed to delete secret", "path", path) 310 - s.sendError(c, http.StatusInternalServerError, "delete_error", "Failed to delete secret", map[string]any{ 311 - "path": path, 312 - "error": err.Error(), 313 - }) 314 - return 315 - } 316 - 317 - response := map[string]any{ 318 - "path": path, 319 - } 320 - 321 - s.sendResponse(c, http.StatusOK, "Secret deleted successfully", response) 322 - } 323 - 324 - // handleListSecrets handles secret listing requests 325 - func (s *Server) handleListSecrets(c *gin.Context) { 326 - ctx := c.Request.Context() 327 - prefix := c.Query("prefix") 328 - 329 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 330 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 331 - return 332 - } 333 - 334 - paths, err := s.hsmClient.ListSecrets(ctx, prefix) 335 - if err != nil { 336 - s.logger.Error(err, "Failed to list secrets", "prefix", prefix) 337 - s.sendError(c, http.StatusInternalServerError, "list_error", "Failed to list secrets", map[string]any{ 338 - "prefix": prefix, 339 - "error": err.Error(), 340 - }) 341 - return 342 - } 343 - 344 - response := map[string]any{ 345 - "prefix": prefix, 346 - "paths": paths, 347 - "count": len(paths), 348 - } 349 - 350 - s.sendResponse(c, http.StatusOK, "Secrets listed successfully", response) 351 - } 352 - 353 - // handleGetChecksum handles checksum requests 354 - func (s *Server) handleGetChecksum(c *gin.Context) { 355 - ctx := c.Request.Context() 356 - path := c.Param("path") 357 - 358 - if path == "" { 359 - s.sendError(c, http.StatusBadRequest, "invalid_path", "Path parameter is required", nil) 360 - return 361 - } 362 - 363 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 364 - s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client not connected", nil) 365 - return 366 - } 367 - 368 - checksum, err := s.hsmClient.GetChecksum(ctx, path) 369 - if err != nil { 370 - s.logger.Error(err, "Failed to get checksum", "path", path) 371 - s.sendError(c, http.StatusInternalServerError, "checksum_error", "Failed to get checksum", map[string]any{ 372 - "path": path, 373 - "error": err.Error(), 374 - }) 375 - return 376 - } 377 - 378 - response := map[string]any{ 379 - "path": path, 380 - "checksum": checksum, 381 - } 382 - 383 - s.sendResponse(c, http.StatusOK, "Checksum retrieved successfully", response) 384 - } 385 - 386 - // handleHealthz handles liveness probe requests 387 - func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { 388 - status := HealthStatus{ 389 - Status: "healthy", 390 - DeviceName: s.deviceName, 391 - HSMConnected: s.hsmClient != nil && s.hsmClient.IsConnected(), 392 - Timestamp: time.Now(), 393 - Uptime: "unknown", // Could track actual uptime 394 - } 395 - 396 - if !status.HSMConnected { 397 - status.Status = "degraded" 398 - w.WriteHeader(http.StatusServiceUnavailable) 399 - } else { 400 - w.WriteHeader(http.StatusOK) 401 - } 402 - 403 - w.Header().Set("Content-Type", "application/json") 404 - // Simple JSON encoding without external dependencies 405 - if _, err := fmt.Fprintf(w, `{"status":"%s","deviceName":"%s","hsmConnected":%t,"timestamp":"%s"}`, 406 - status.Status, status.DeviceName, status.HSMConnected, status.Timestamp.Format(time.RFC3339)); err != nil { 407 - s.logger.Error(err, "Failed to write health response") 408 - } 409 - } 410 - 411 - // handleReadyz handles readiness probe requests 412 - func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { 413 - // Agent is ready if HSM client is connected 414 - if s.hsmClient == nil || !s.hsmClient.IsConnected() { 415 - w.WriteHeader(http.StatusServiceUnavailable) 416 - w.Header().Set("Content-Type", "application/json") 417 - if _, err := fmt.Fprintf(w, `{"status":"not_ready","reason":"hsm_not_connected"}`); err != nil { 418 - s.logger.Error(err, "Failed to write readiness response") 419 - } 420 - return 421 - } 422 - 423 - w.WriteHeader(http.StatusOK) 424 - w.Header().Set("Content-Type", "application/json") 425 - if _, err := fmt.Fprintf(w, `{"status":"ready"}`); err != nil { 426 - s.logger.Error(err, "Failed to write readiness response") 427 - } 428 - } 429 - 430 - // sendResponse sends a successful API response 431 - func (s *Server) sendResponse(c *gin.Context, statusCode int, message string, data any) { 432 - response := AgentResponse{ 433 - Success: true, 434 - Message: message, 435 - Data: data, 436 - } 437 - c.JSON(statusCode, response) 438 - } 439 - 440 - // sendError sends an error API response 441 - func (s *Server) sendError(c *gin.Context, statusCode int, code, message string, details map[string]any) { 442 - response := AgentResponse{ 443 - Success: false, 444 - Error: &AgentError{ 445 - Code: code, 446 - Message: message, 447 - Details: details, 448 - }, 449 - } 450 - c.JSON(statusCode, response) 451 - } 452 - 453 - // loggingMiddleware provides request logging 454 - func (s *Server) loggingMiddleware() gin.HandlerFunc { 455 - return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { 456 - s.logger.Info("HTTP request", 457 - "method", param.Method, 458 - "path", param.Path, 459 - "status", param.StatusCode, 460 - "latency", param.Latency, 461 - "ip", param.ClientIP, 462 - ) 463 - return "" 464 - }) 465 - } 466 - 467 - // authMiddleware provides basic authentication/authorization 468 - func (s *Server) authMiddleware() gin.HandlerFunc { 469 - return func(c *gin.Context) { 470 - // For now, no authentication - this runs in a secure cluster 471 - // In production, you might want to add: 472 - // - Service account token validation 473 - // - mTLS client certificate validation 474 - // - Custom authentication headers 475 - 476 - // Add request context 477 - c.Set("device_name", s.deviceName) 478 - c.Next() 479 - } 480 - }
+9 -17
internal/api/proxy_handlers.go
··· 22 22 "github.com/gin-gonic/gin" 23 23 ) 24 24 25 - // handleProxyRequest handles all HSM API requests by proxying to agent pods 25 + // handleProxyRequest handles all HSM API requests by converting to gRPC calls 26 26 func (s *Server) handleProxyRequest(c *gin.Context) { 27 27 // Extract namespace from request or use default 28 28 namespace := c.GetHeader("X-Namespace") 29 29 if namespace == "" { 30 30 namespace = "secrets" // Default namespace 31 31 } 32 - 33 - // Find available agent and proxy the request 34 - agentEndpoint, err := s.findAvailableAgent(c.Request.Context(), namespace) 32 + 33 + // Find available agent (returns device name) 34 + deviceName, err := s.findAvailableAgent(c.Request.Context(), namespace) 35 35 if err != nil { 36 36 s.sendError(c, http.StatusServiceUnavailable, "no_agent", "No HSM agents available", map[string]any{ 37 37 "error": err.Error(), ··· 39 39 return 40 40 } 41 41 42 - // Build the full path for the agent API 43 - // Request path will be like /api/v1/hsm/secrets/my-secret 44 - // Agent expects the same path 45 - path := c.Request.URL.Path 46 - if c.Request.URL.RawQuery != "" { 47 - path += "?" + c.Request.URL.RawQuery 48 - } 49 - 50 - s.logger.V(1).Info("Proxying request to agent", 42 + s.logger.V(1).Info("Converting HTTP request to gRPC call", 51 43 "method", c.Request.Method, 52 - "path", path, 53 - "agent", agentEndpoint) 44 + "path", c.Request.URL.Path, 45 + "device", deviceName) 54 46 55 - // Proxy to agent 56 - s.proxyToAgent(c, agentEndpoint, path) 47 + // Convert HTTP request to gRPC call 48 + s.proxyToAgent(c, deviceName, c.Request.URL.Path) 57 49 } 58 50 59 51 // setupProxyRoutes sets up proxy routes for HSM operations
+67 -65
internal/api/server.go
··· 17 17 package api 18 18 19 19 import ( 20 - "bytes" 21 20 "context" 22 21 "fmt" 23 - "io" 24 22 "net/http" 23 + "strings" 25 24 "time" 26 25 27 26 "github.com/gin-gonic/gin" ··· 32 31 hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1" 33 32 "github.com/evanjarrett/hsm-secrets-operator/internal/agent" 34 33 "github.com/evanjarrett/hsm-secrets-operator/internal/discovery" 34 + "github.com/evanjarrett/hsm-secrets-operator/internal/hsm" 35 35 ) 36 36 37 37 // Server represents the HSM REST API server that proxies requests to agent pods ··· 174 174 175 175 // findAvailableAgent finds an available HSM agent for handling requests 176 176 func (s *Server) findAvailableAgent(ctx context.Context, namespace string) (string, error) { 177 - // List all HSMDevices in the namespace to find ready ones 177 + if s.agentManager == nil { 178 + return "", fmt.Errorf("agent manager not available") 179 + } 180 + 181 + // List all HSMDevices to find one with an active agent 178 182 var hsmDeviceList hsmv1alpha1.HSMDeviceList 179 183 if err := s.client.List(ctx, &hsmDeviceList, client.InNamespace(namespace)); err != nil { 180 184 return "", fmt.Errorf("failed to list HSM devices: %w", err) 181 185 } 182 186 183 - // Look for devices with associated HSMPools that have available agents 187 + // Check if any device has an active agent with pod IPs 184 188 for _, device := range hsmDeviceList.Items { 185 - // Check the HSMPool for this device 186 - poolName := device.Name + "-pool" 187 - pool := &hsmv1alpha1.HSMPool{} 188 - 189 - err := s.client.Get(ctx, client.ObjectKey{ 190 - Name: poolName, 191 - Namespace: device.Namespace, 192 - }, pool) 193 - 194 - if err == nil && pool.Status.Phase == hsmv1alpha1.HSMPoolPhaseReady && len(pool.Status.AggregatedDevices) > 0 { 195 - // Generate agent endpoint for gRPC communication 196 - agentName := fmt.Sprintf("hsm-agent-%s", device.Name) 197 - agentEndpoint := fmt.Sprintf("%s.%s.svc.cluster.local:9090", agentName, namespace) 198 - 199 - // Test if agent is responsive using health check on HTTP port 200 - healthURL := fmt.Sprintf("http://%s.%s.svc.cluster.local:8093/healthz", agentName, namespace) 201 - resp, err := s.httpClient.Get(healthURL) 202 - if err == nil && resp.StatusCode == 200 { 203 - _ = resp.Body.Close() 204 - return agentEndpoint, nil 205 - } 206 - if resp != nil { 207 - _ = resp.Body.Close() 208 - } 189 + if podIPs, err := s.agentManager.GetAgentPodIPs(device.Name); err == nil && len(podIPs) > 0 { 190 + // Return the device name (we'll use AgentManager to get the actual client) 191 + return device.Name, nil 209 192 } 210 193 } 211 194 212 195 return "", fmt.Errorf("no available HSM agents found") 213 196 } 214 197 215 - // proxyToAgent forwards the request to an HSM agent and returns the response 216 - func (s *Server) proxyToAgent(c *gin.Context, agentEndpoint, path string) { 217 - // Build agent URL 218 - agentURL := agentEndpoint + path 219 - 220 - // Create request with same method and body 221 - var bodyReader io.Reader 222 - if c.Request.Body != nil { 223 - bodyBytes, err := io.ReadAll(c.Request.Body) 224 - if err != nil { 225 - s.sendError(c, http.StatusInternalServerError, "proxy_error", "Failed to read request body", nil) 226 - return 227 - } 228 - bodyReader = bytes.NewReader(bodyBytes) 198 + // proxyToAgent forwards the request to an HSM agent via gRPC and returns the HTTP response 199 + func (s *Server) proxyToAgent(c *gin.Context, deviceName, path string) { 200 + // Parse the REST API path and convert to gRPC call 201 + method := c.Request.Method 202 + 203 + // Extract namespace for finding device 204 + namespace := c.GetHeader("X-Namespace") 205 + if namespace == "" { 206 + namespace = "secrets" 229 207 } 230 208 231 - req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, agentURL, bodyReader) 209 + // Create gRPC client for this device 210 + grpcClient, err := s.createGRPCClient(c.Request.Context(), deviceName, namespace) 232 211 if err != nil { 233 - s.sendError(c, http.StatusInternalServerError, "proxy_error", "Failed to create agent request", nil) 212 + s.sendError(c, http.StatusServiceUnavailable, "grpc_error", "Failed to connect to HSM agent", map[string]any{ 213 + "error": err.Error(), 214 + }) 234 215 return 235 216 } 236 - 237 - // Copy headers 238 - for key, values := range c.Request.Header { 239 - for _, value := range values { 240 - req.Header.Add(key, value) 217 + defer func() { 218 + if closeErr := grpcClient.Close(); closeErr != nil { 219 + s.logger.Error(closeErr, "Failed to close gRPC client") 241 220 } 221 + }() 222 + 223 + // For now, just implement ListSecrets to test gRPC connection 224 + if method == "GET" && strings.Contains(path, "/secrets") { 225 + s.handleListSecrets(c, grpcClient) 226 + } else { 227 + s.sendError(c, http.StatusNotImplemented, "not_implemented", "gRPC routing not yet implemented for this endpoint", nil) 242 228 } 229 + } 243 230 244 - // Make request to agent 245 - resp, err := s.httpClient.Do(req) 231 + // createGRPCClient creates a gRPC client for the specified device using AgentManager 232 + func (s *Server) createGRPCClient(ctx context.Context, deviceName, _ string) (hsm.Client, error) { 233 + // Use the AgentManager to create a gRPC client directly 234 + if s.agentManager == nil { 235 + return nil, fmt.Errorf("agent manager not available") 236 + } 237 + 238 + // Create gRPC client using AgentManager's existing method 239 + grpcClient, err := s.agentManager.CreateSingleGRPCClient(ctx, deviceName, s.logger) 246 240 if err != nil { 247 - s.sendError(c, http.StatusBadGateway, "agent_error", "Failed to connect to HSM agent", map[string]any{ 241 + return nil, fmt.Errorf("failed to create gRPC client for device %s: %w", deviceName, err) 242 + } 243 + 244 + return grpcClient, nil 245 + } 246 + 247 + // handleListSecrets handles GET /api/v1/hsm/secrets via gRPC 248 + func (s *Server) handleListSecrets(c *gin.Context, grpcClient hsm.Client) { 249 + // Get query parameters 250 + prefix := c.Query("prefix") 251 + 252 + // Call gRPC ListSecrets 253 + secrets, err := grpcClient.ListSecrets(c.Request.Context(), prefix) 254 + if err != nil { 255 + s.sendError(c, http.StatusInternalServerError, "grpc_error", "Failed to list secrets from HSM agent", map[string]any{ 248 256 "error": err.Error(), 249 257 }) 250 258 return 251 259 } 252 - defer func() { _ = resp.Body.Close() }() 253 - 254 - // Copy response headers 255 - for key, values := range resp.Header { 256 - for _, value := range values { 257 - c.Header(key, value) 258 - } 259 - } 260 - 261 - // Copy status and body 262 - c.Status(resp.StatusCode) 263 - if _, err := io.Copy(c.Writer, resp.Body); err != nil { 264 - s.logger.Error(err, "Failed to copy agent response") 260 + 261 + // Return the secrets in the expected format 262 + response := map[string]any{ 263 + "secrets": secrets, 264 + "count": len(secrets), 265 265 } 266 + 267 + s.sendResponse(c, http.StatusOK, "Secrets listed successfully", response) 266 268 }
+6 -19
internal/modes/agent/agent.go
··· 47 47 var slotID int 48 48 var tokenLabel string 49 49 var pin string 50 - var useGRPC bool 51 - 52 50 fs.StringVar(&deviceName, "device-name", "", "Name of the HSM device this agent serves") 53 - fs.IntVar(&port, "port", 9090, "Port for the HSM agent API (gRPC by default)") 51 + fs.IntVar(&port, "port", 9090, "Port for the HSM agent gRPC API") 54 52 fs.IntVar(&healthPort, "health-port", 8093, "Port for health checks") 55 53 fs.StringVar(&pkcs11LibraryPath, "pkcs11-library", "", "Path to PKCS#11 library") 56 54 fs.IntVar(&slotID, "slot-id", 0, "PKCS#11 slot ID") 57 55 fs.StringVar(&tokenLabel, "token-label", "", "PKCS#11 token label") 58 56 fs.StringVar(&pin, "pin", "", "PKCS#11 PIN (use environment variable HSM_PIN for security)") 59 - fs.BoolVar(&useGRPC, "use-grpc", true, "Use gRPC server instead of HTTP (default: true)") 60 57 61 58 // Parse agent-specific flags from the remaining unparsed arguments 62 59 if err := fs.Parse(args); err != nil { ··· 86 83 "device", deviceName, 87 84 "port", port, 88 85 "health-port", healthPort, 89 - "protocol", map[bool]string{true: "gRPC", false: "HTTP"}[useGRPC], 86 + "protocol", "gRPC", 90 87 "pkcs11-library", pkcs11LibraryPath, 91 88 "slot-id", slotID, 92 89 "token-label", tokenLabel, ··· 143 140 cancel() 144 141 }() 145 142 146 - // Start server 143 + // Start gRPC server 147 144 setupLog.Info("HSM agent ready", "device", deviceName) 148 145 149 - var err error 150 - if useGRPC { 151 - // Create gRPC server 152 - grpcServer := agent.NewGRPCServer(hsmClient, deviceName, port, healthPort, setupLog) 153 - err = grpcServer.Start(ctx) 154 - } else { 155 - // Create HTTP server (legacy) 156 - httpServer := agent.NewServer(hsmClient, deviceName, port, healthPort, setupLog) 157 - err = httpServer.Start(ctx) 158 - } 159 - 160 - if err != nil { 161 - setupLog.Error(err, "Server failed") 146 + grpcServer := agent.NewGRPCServer(hsmClient, deviceName, port, healthPort, setupLog) 147 + if err := grpcServer.Start(ctx); err != nil { 148 + setupLog.Error(err, "gRPC server failed") 162 149 return err 163 150 } 164 151