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.

implement web api

+989 -150
+2
Dockerfile
··· 38 38 COPY cmd/ cmd/ 39 39 COPY api/ api/ 40 40 COPY internal/ internal/ 41 + COPY web/ web/ 41 42 42 43 # Build manager and discovery without CGO (they use mock clients) 43 44 RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/manager/main.go ··· 57 58 COPY --from=builder /workspace/manager . 58 59 COPY --from=builder /workspace/agent . 59 60 COPY --from=builder /workspace/discovery . 61 + COPY --from=builder /workspace/web ./web/ 60 62 COPY entrypoint.sh /entrypoint.sh 61 63 RUN chmod +x /entrypoint.sh 62 64 USER 65532:65532
+2
Dockerfile.testing
··· 15 15 COPY cmd/ cmd/ 16 16 COPY api/ api/ 17 17 COPY internal/ internal/ 18 + COPY web/ web/ 18 19 19 20 # Build all binaries without CGO for testing (uses mock HSM clients) 20 21 RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/manager/main.go ··· 29 30 COPY --from=builder /workspace/manager . 30 31 COPY --from=builder /workspace/agent . 31 32 COPY --from=builder /workspace/discovery . 33 + COPY --from=builder /workspace/web ./web/ 32 34 COPY entrypoint.sh /entrypoint.sh 33 35 RUN chmod +x /entrypoint.sh 34 36 USER 65532:65532
-137
NEW-ARCHITECTURE.md
··· 1 - # New Race Condition-Free Architecture 2 - 3 - ## Overview 4 - 5 - The HSM Secrets Operator has been refactored to eliminate race conditions through clear separation of concerns and ephemeral coordination using pod annotations. 6 - 7 - ## Architecture Components 8 - 9 - ### 1. HSMDevice (Readonly Spec) 10 - ```yaml 11 - apiVersion: hsm.j5t.io/v1alpha1 12 - kind: HSMDevice 13 - metadata: 14 - name: pico-hsm 15 - spec: 16 - deviceType: "PicoHSM" 17 - discovery: 18 - autoDiscovery: true 19 - pkcs11: 20 - libraryPath: "/usr/lib/libsc-hsm-pkcs11.so" 21 - slotId: 0 22 - pinSecret: 23 - name: "pico-hsm-pin" 24 - key: "pin" 25 - ``` 26 - 27 - **Purpose**: Device specification and configuration only. No dynamic status. 28 - 29 - ### 2. HSMPool (Aggregated Status) 30 - ```yaml 31 - apiVersion: hsm.j5t.io/v1alpha1 32 - kind: HSMPool 33 - metadata: 34 - name: pico-hsm-pool 35 - ownerReferences: 36 - - kind: HSMDevice 37 - name: pico-hsm 38 - spec: 39 - hsmDeviceRef: pico-hsm 40 - gracePeriod: 5m 41 - status: 42 - phase: Ready 43 - totalDevices: 2 44 - availableDevices: 2 45 - reportingPods: 46 - - podName: discovery-node1 47 - devicesFound: 1 48 - fresh: true 49 - - podName: discovery-node2 50 - devicesFound: 1 51 - fresh: true 52 - ``` 53 - 54 - **Purpose**: Aggregates discovery results from all pods with grace periods for outages. 55 - 56 - ### 3. Pod Annotations (Ephemeral Reports) 57 - ```yaml 58 - apiVersion: v1 59 - kind: Pod 60 - metadata: 61 - name: discovery-node1 62 - annotations: 63 - hsm.j5t.io/device-report: | 64 - { 65 - "hsmDeviceName": "pico-hsm", 66 - "reportingNode": "node1", 67 - "discoveredDevices": [ 68 - { 69 - "devicePath": "/dev/bus/usb/001/015", 70 - "serialNumber": "DC6A33145E23A42A", 71 - "lastSeen": "2025-08-19T10:00:00Z" 72 - } 73 - ], 74 - "lastReportTime": "2025-08-19T10:00:00Z", 75 - "discoveryStatus": "completed" 76 - } 77 - ``` 78 - 79 - **Purpose**: Each discovery pod reports its findings via its own annotations. Auto-cleanup when pods disappear. 80 - 81 - ## Data Flow 82 - 83 - ``` 84 - 1. User creates HSMDevice → Manager creates HSMPool 85 - 2. Discovery pods see HSMDevice → Update own annotations 86 - 3. HSMPool controller watches annotations → Aggregates into pool status 87 - 4. Pool status shows complete device availability across cluster 88 - ``` 89 - 90 - ## Benefits 91 - 92 - ✅ **No Race Conditions**: Each resource has single owner 93 - ✅ **Automatic Cleanup**: Pod dies → annotations disappear → no stale data 94 - ✅ **Grace Periods**: 5-minute buffer prevents agent churn during outages 95 - ✅ **Kubernetes Native**: Standard patterns (annotations, owner refs, watches) 96 - ✅ **Scalable**: Works with any number of discovery pods 97 - 98 - ## Migration Guide 99 - 100 - ### Old vs New 101 - 102 - **Before**: 103 - - HSMDevice had complex status with coordination 104 - - Multiple pods fought over same status 105 - - Race conditions and reconciliation loops 106 - 107 - **After**: 108 - - HSMDevice: readonly spec only 109 - - HSMPool: aggregated status 110 - - Pod annotations: ephemeral reports 111 - 112 - ### Deployment Changes 113 - 114 - 1. **New CRDs**: HSMPool CRD added alongside HSMDevice 115 - 2. **Pod Environment**: Discovery pods need `POD_NAME` and `POD_NAMESPACE` env vars 116 - 3. **RBAC**: Added permissions for HSMPools and pod annotations 117 - 118 - ### Expected Behavior 119 - 120 - ```bash 121 - # Create HSMDevice 122 - kubectl apply -f examples/new-architecture/test-hsmdevice.yaml 123 - 124 - # Manager auto-creates HSMPool 125 - kubectl get hsmpool 126 - # NAME HSMDEVICE TOTAL AVAILABLE PHASE 127 - # pico-hsm-test-pool pico-hsm-test 2 2 Ready 128 - 129 - # Check pod reports 130 - kubectl get pods -l app.kubernetes.io/component=discovery \ 131 - -o jsonpath='{range .items[*]}{.metadata.name}: {.metadata.annotations.hsm\.j5t\.io/device-report}{"\n"}{end}' 132 - 133 - # Pool aggregates all reports 134 - kubectl get hsmpool pico-hsm-test-pool -o yaml 135 - ``` 136 - 137 - The new architecture is production-ready and eliminates all race conditions while providing clear visibility into device discovery across the cluster.
+51 -13
README.md
··· 4 4 5 5 ## Description 6 6 7 - The HSM Secrets Operator implements a controller pattern that maintains bidirectional synchronization between HSM binary data files and Kubernetes Secret objects. It uses a dual-binary architecture with automatic USB device discovery and dynamic agent deployment to provide secure, hardware-backed secret management in Kubernetes environments. 7 + The HSM Secrets Operator implements a controller pattern that maintains bidirectional synchronization between HSM binary data files and Kubernetes Secret objects. It uses a four-binary architecture with gRPC communication, automatic USB device discovery, and dynamic agent deployment to provide secure, hardware-backed secret management in Kubernetes environments. 8 8 9 9 ### Key Features 10 10 ··· 12 12 - **Bidirectional Sync**: Automatic synchronization between HSM storage and Kubernetes Secrets 13 13 - **Device Discovery**: Automatic USB HSM device detection with support for multiple device types 14 14 - **Agent Architecture**: Dynamic deployment of HSM agent pods with node affinity for direct hardware access 15 + - **gRPC Communication**: High-performance gRPC protocol for manager-agent communication with fallback to HTTP 15 16 - **Unified API**: Single REST API endpoint that routes operations to appropriate HSM agents 16 17 - **Secret Portability**: Move secrets between clusters by carrying the HSM device 17 18 - **Multi-Device Support**: Support for Pico HSM, SmartCard-HSM, YubiKey HSM, and custom devices 18 19 19 20 ### Architecture 20 21 21 - The operator consists of three main components: 22 + The operator consists of four main components: 22 23 23 - 1. **Manager**: Orchestrates HSMSecret resources, deploys agents, and provides unified API proxy 24 - 2. **Discovery**: DaemonSet that discovers USB HSM devices on cluster nodes 25 - 3. **Agent**: Dynamically deployed pods that handle direct HSM communication on nodes with devices 24 + 1. **Manager**: Orchestrates HSMSecret resources, deploys agents, and provides unified REST API proxy (port 8090) 25 + 2. **Discovery**: DaemonSet that discovers USB HSM devices on cluster nodes and reports via pod annotations 26 + 3. **Agent**: Dynamically deployed pods that handle direct HSM communication via gRPC (port 9090) with HTTP health checks (port 8093) 27 + 4. **Test HSM**: Utility for HSM operations testing and debugging 26 28 27 - This architecture ensures that HSM operations only occur on nodes with physical device access while providing a centralized management interface. 29 + **Communication Architecture:** 30 + - **Manager ↔ Agent**: gRPC for efficient, type-safe HSM operations 31 + - **Discovery → Manager**: Pod annotations for race-free device reporting 32 + - **External → Manager**: REST API for user/application access 33 + - **Protocol Buffers**: Structured message definitions in `api/proto/hsm/v1/hsm.proto` 34 + 35 + This architecture ensures that HSM operations only occur on nodes with physical device access while providing a centralized management interface with high-performance communication. 28 36 29 37 ## Getting Started 30 38 ··· 34 42 - Docker 17.03+ (for building images) 35 43 - kubectl with cluster-admin privileges 36 44 - HSM device (Pico HSM, SmartCard-HSM, YubiKey HSM, or compatible PKCS#11 device) 45 + - **For development**: buf tool (`go install github.com/bufbuild/buf/cmd/buf@latest`) 37 46 38 47 ### Deployment Options 39 48 ··· 110 119 # List secrets 111 120 curl http://localhost:8090/api/v1/hsm/secrets 112 121 113 - # Check discovered HSM devices 122 + # Check discovered HSM devices 114 123 kubectl get hsmdevices 115 124 125 + # Check HSM pools (aggregated device discovery) 126 + kubectl get hsmpools 127 + 116 128 # Check agent pods (deployed automatically when devices are ready) 117 - kubectl get pods -l app=hsm-agent 129 + kubectl get pods -l app.kubernetes.io/component=agent 130 + 131 + # Test gRPC agent health (from within cluster) 132 + kubectl exec -it <agent-pod> -- curl http://localhost:8093/healthz 118 133 ``` 119 134 120 135 ### Uninstallation ··· 206 221 # Generate manifests after CRD changes 207 222 make manifests 208 223 209 - # Build binaries 224 + # Generate protocol buffer code after .proto changes 225 + buf generate 226 + 227 + # Build all binaries (manager, discovery, agent, test-hsm) 210 228 make build 211 229 ``` 212 230 ··· 219 237 220 238 ### Architecture Notes 221 239 222 - - **Manager**: Handles HSMSecret CRDs and agent deployment 223 - - **Discovery**: DaemonSet for USB device discovery 224 - - **Agent**: Dynamic pods for direct HSM communication 225 - - **API**: Unified proxy that routes to agent pods 240 + - **Manager**: Handles HSMSecret CRDs, agent deployment, and REST API proxy (port 8090) 241 + - **Discovery**: DaemonSet for USB device discovery with pod annotation reporting 242 + - **Agent**: Dynamic pods for direct HSM communication via gRPC (port 9090) 243 + - **gRPC Protocol**: Type-safe communication defined in `api/proto/hsm/v1/hsm.proto` 244 + - **Health Checks**: HTTP endpoints on port 8093 for Kubernetes probes 245 + 246 + ### Protocol Buffer Development 247 + 248 + When modifying the gRPC service definition: 249 + 250 + ```bash 251 + # 1. Edit the protocol definition 252 + vim api/proto/hsm/v1/hsm.proto 253 + 254 + # 2. Generate Go code 255 + buf generate 256 + 257 + # 3. Lint and format 258 + buf lint 259 + buf format -w api/proto/hsm/v1/hsm.proto 260 + 261 + # 4. Run tests to ensure compatibility 262 + make test 263 + ``` 226 264 227 265 **NOTE:** Run `make help` for more information on all potential `make` targets 228 266
+6
internal/api/proxy_handlers.go
··· 58 58 59 59 // setupProxyRoutes sets up proxy routes for HSM operations 60 60 func (s *Server) setupProxyRoutes() { 61 + // Serve web UI static files 62 + s.router.Static("/web", "./web") 63 + s.router.GET("/", func(c *gin.Context) { 64 + c.Redirect(http.StatusFound, "/web/") 65 + }) 66 + 61 67 // Create API v1 group 62 68 v1 := s.router.Group("/api/v1") 63 69 {
+132
web/README.md
··· 1 + # HSM Secrets Manager Web UI 2 + 3 + A simple web interface for managing Hardware Security Module (HSM) secrets through the HSM Secrets Operator. 4 + 5 + ## Features 6 + 7 + - **📋 List Secrets**: View all secrets stored in your HSM 8 + - **➕ Create Secrets**: Add new secrets with JSON key-value pairs 9 + - **🔍 View Details**: Examine secret contents and metadata 10 + - **🗑️ Delete Secrets**: Remove secrets from both HSM and Kubernetes 11 + - **📊 Health Monitoring**: Check API and HSM status 12 + - **🔄 Auto-refresh**: Automatically updates every 30 seconds 13 + 14 + ## Usage 15 + 16 + ### Starting the Web UI 17 + 18 + The web UI is served by the HSM Secrets Operator manager on port 8090 by default: 19 + 20 + 1. **Using kubectl port-forward** (for local development): 21 + ```bash 22 + kubectl port-forward -n hsm-secrets-operator-system service/hsm-secrets-operator-manager-service 8090:8090 23 + ``` 24 + 25 + 2. **Using ingress** (for production): 26 + Configure your ingress controller to route to the manager service on port 8090. 27 + 28 + 3. **Access the UI**: 29 + Open your browser to: `http://localhost:8090` 30 + 31 + ### Creating Secrets 32 + 33 + 1. Click **"➕ Create New Secret"** 34 + 2. Enter a **Secret Name** (this becomes the HSM path) 35 + 3. Add **Key-Value Pairs**: 36 + - Click the **➕** button to add a new key-value pair 37 + - Enter the key name (e.g., `api_key`, `database_password`) 38 + - Enter the corresponding value 39 + - Use **➖** to remove pairs you don't need 40 + - Add as many pairs as needed for your secret 41 + 4. Click **"Create Secret"** 42 + 43 + **Key Naming Rules:** 44 + - Must start with a letter 45 + - Can contain letters, numbers, and underscores only 46 + - Examples: `api_key`, `db_password`, `webhook_secret` 47 + 48 + ### Viewing Secrets 49 + 50 + 1. Click **"👁️ View"** next to any secret in the list 51 + 2. See the full JSON structure and metadata 52 + 3. Copy individual values as needed 53 + 54 + ### Managing Secrets 55 + 56 + - **Refresh**: Click 🔄 to manually refresh the list 57 + - **Delete**: Click 🗑️ and confirm to permanently remove a secret 58 + - **Auto-sync**: The UI automatically refreshes every 30 seconds 59 + 60 + ## API Integration 61 + 62 + The web UI communicates with the HSM Secrets Operator's REST API: 63 + 64 + - **List Secrets**: `GET /api/v1/hsm/secrets` 65 + - **Get Secret**: `GET /api/v1/hsm/secrets/{name}` 66 + - **Create Secret**: `POST /api/v1/hsm/secrets/{name}` 67 + - **Delete Secret**: `DELETE /api/v1/hsm/secrets/{name}` 68 + - **Health Check**: `GET /api/v1/health` 69 + 70 + ## Security Considerations 71 + 72 + - The web UI serves static files from the manager pod 73 + - All API calls go through the manager, which proxies to HSM agent pods 74 + - Secrets are displayed in the browser - use HTTPS in production 75 + - Consider network policies to restrict access to the web interface 76 + 77 + ## Ingress Example 78 + 79 + ```yaml 80 + apiVersion: networking.k8s.io/v1 81 + kind: Ingress 82 + metadata: 83 + name: hsm-secrets-ui 84 + namespace: hsm-secrets-operator-system 85 + annotations: 86 + nginx.ingress.kubernetes.io/ssl-redirect: "true" 87 + spec: 88 + tls: 89 + - hosts: 90 + - hsm-secrets.example.com 91 + secretName: hsm-secrets-tls 92 + rules: 93 + - host: hsm-secrets.example.com 94 + http: 95 + paths: 96 + - path: / 97 + pathType: Prefix 98 + backend: 99 + service: 100 + name: hsm-secrets-operator-manager-service 101 + port: 102 + number: 8090 103 + ``` 104 + 105 + ## Troubleshooting 106 + 107 + ### UI Not Loading 108 + - Check that the manager pod is running: `kubectl get pods -n hsm-secrets-operator-system` 109 + - Verify port-forward is active: `netstat -an | grep 8090` 110 + - Check manager logs: `kubectl logs -n hsm-secrets-operator-system -l app.kubernetes.io/name=hsm-secrets-operator` 111 + 112 + ### API Errors 113 + - Ensure HSM agents are running and healthy 114 + - Check HSMPool status: `kubectl get hsmpool` 115 + - Verify HSM devices are discovered: `kubectl get hsmdevice` 116 + 117 + ### No Secrets Visible 118 + - Confirm secrets exist via CLI: `examples/api/list-secrets.sh` 119 + - Check agent connectivity from manager pod 120 + - Verify PKCS#11 configuration in HSMDevice CRDs 121 + 122 + ## Development 123 + 124 + The web UI consists of: 125 + - `index.html`: Main interface with responsive design 126 + - `app.js`: JavaScript API client and UI logic 127 + - Served via Gin router's static file handler 128 + 129 + To modify the UI: 130 + 1. Edit files in the `web/` directory 131 + 2. Rebuild the manager: `make build` 132 + 3. Redeploy or restart the manager pod
+406
web/app.js
··· 1 + class HSMSecretsAPI { 2 + constructor(baseUrl = '') { 3 + this.baseUrl = baseUrl; 4 + this.apiPath = '/api/v1'; 5 + } 6 + 7 + async request(path, options = {}) { 8 + const url = `${this.baseUrl}${this.apiPath}${path}`; 9 + const config = { 10 + headers: { 11 + 'Content-Type': 'application/json', 12 + ...options.headers 13 + }, 14 + ...options 15 + }; 16 + 17 + try { 18 + const response = await fetch(url, config); 19 + const data = await response.json(); 20 + 21 + if (!response.ok) { 22 + throw new Error(data.error?.message || `HTTP ${response.status}`); 23 + } 24 + 25 + return data; 26 + } catch (error) { 27 + console.error('API Request failed:', error); 28 + throw error; 29 + } 30 + } 31 + 32 + async getHealth() { 33 + return this.request('/health'); 34 + } 35 + 36 + async listSecrets(page = 1, pageSize = 100) { 37 + return this.request(`/hsm/secrets?page=${page}&page_size=${pageSize}`); 38 + } 39 + 40 + async getSecret(secretName) { 41 + return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`); 42 + } 43 + 44 + async createSecret(secretName, data) { 45 + return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`, { 46 + method: 'POST', 47 + body: JSON.stringify({ data }) 48 + }); 49 + } 50 + 51 + async deleteSecret(secretName) { 52 + return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`, { 53 + method: 'DELETE' 54 + }); 55 + } 56 + } 57 + 58 + class HSMSecretsUI { 59 + constructor() { 60 + this.api = new HSMSecretsAPI(); 61 + this.secrets = []; 62 + this.init(); 63 + } 64 + 65 + init() { 66 + this.kvPairCounter = 0; 67 + this.setupEventListeners(); 68 + this.loadInitialData(); 69 + this.initializeCreateForm(); 70 + } 71 + 72 + initializeCreateForm() { 73 + // Add initial empty key-value pair to the form 74 + this.addKeyValuePair(); 75 + } 76 + 77 + setupEventListeners() { 78 + const createForm = document.getElementById('createForm'); 79 + createForm.addEventListener('submit', (e) => this.handleCreateSecret(e)); 80 + 81 + // Auto-refresh every 30 seconds 82 + setInterval(() => this.refreshSecrets(), 30000); 83 + } 84 + 85 + async loadInitialData() { 86 + await this.checkAPIHealth(); 87 + await this.loadSecrets(); 88 + } 89 + 90 + async checkAPIHealth() { 91 + try { 92 + const health = await this.api.getHealth(); 93 + const statusElement = document.getElementById('apiStatus'); 94 + 95 + if (health.success && health.data.status === 'healthy') { 96 + statusElement.textContent = '✅ Healthy'; 97 + statusElement.style.color = '#22543d'; 98 + } else { 99 + statusElement.textContent = '⚠️ Degraded'; 100 + statusElement.style.color = '#dd6b20'; 101 + } 102 + } catch (error) { 103 + const statusElement = document.getElementById('apiStatus'); 104 + statusElement.textContent = '❌ Error'; 105 + statusElement.style.color = '#c53030'; 106 + console.error('Health check failed:', error); 107 + } 108 + } 109 + 110 + async loadSecrets() { 111 + const listElement = document.getElementById('secretsList'); 112 + listElement.innerHTML = '<div class="loading">Loading secrets...</div>'; 113 + 114 + try { 115 + const response = await this.api.listSecrets(); 116 + this.secrets = response.data.paths || []; 117 + 118 + document.getElementById('totalSecrets').textContent = this.secrets.length; 119 + 120 + this.renderSecretsList(); 121 + } catch (error) { 122 + this.showError(listElement, `Failed to load secrets: ${error.message}`); 123 + } 124 + } 125 + 126 + renderSecretsList() { 127 + const listElement = document.getElementById('secretsList'); 128 + 129 + if (this.secrets.length === 0) { 130 + listElement.innerHTML = '<p style="text-align: center; color: #666; padding: 20px;">No secrets found. Create your first secret!</p>'; 131 + return; 132 + } 133 + 134 + listElement.innerHTML = this.secrets.map(secretName => ` 135 + <div class="secret-item"> 136 + <div class="secret-name">🔐 ${this.escapeHtml(secretName)}</div> 137 + <div class="secret-actions"> 138 + <button class="btn btn-secondary" onclick="ui.viewSecret('${this.escapeHtml(secretName)}')"> 139 + 👁️ View 140 + </button> 141 + <button class="btn btn-danger" onclick="ui.deleteSecret('${this.escapeHtml(secretName)}')"> 142 + 🗑️ Delete 143 + </button> 144 + </div> 145 + </div> 146 + `).join(''); 147 + } 148 + 149 + async viewSecret(secretName) { 150 + const viewSection = document.getElementById('viewSection'); 151 + const viewMessage = document.getElementById('viewMessage'); 152 + const detailsElement = document.getElementById('secretDetails'); 153 + 154 + viewSection.style.display = 'block'; 155 + viewMessage.innerHTML = ''; 156 + detailsElement.innerHTML = '<div class="loading">Loading secret details...</div>'; 157 + 158 + // Scroll to view section 159 + viewSection.scrollIntoView({ behavior: 'smooth' }); 160 + 161 + try { 162 + const response = await this.api.getSecret(secretName); 163 + const secretData = response.data; 164 + 165 + detailsElement.innerHTML = ` 166 + <h3>Secret: ${this.escapeHtml(secretName)}</h3> 167 + <div style="margin: 15px 0;"> 168 + <strong>Path:</strong> ${this.escapeHtml(secretData.path || secretName)}<br> 169 + <strong>Checksum:</strong> ${this.escapeHtml(secretData.checksum || 'N/A')}<br> 170 + <strong>Size:</strong> ${secretData.data ? Object.keys(secretData.data).length : 0} keys 171 + </div> 172 + <div> 173 + <strong>Data:</strong> 174 + <div class="json-preview">${this.escapeHtml(JSON.stringify(secretData.data || {}, null, 2))}</div> 175 + </div> 176 + `; 177 + } catch (error) { 178 + this.showError(viewMessage, `Failed to load secret: ${error.message}`); 179 + detailsElement.innerHTML = ''; 180 + } 181 + } 182 + 183 + async deleteSecret(secretName) { 184 + if (!confirm(`Are you sure you want to delete the secret "${secretName}"? This action cannot be undone.`)) { 185 + return; 186 + } 187 + 188 + try { 189 + await this.api.deleteSecret(secretName); 190 + this.showSuccess(`Secret "${secretName}" deleted successfully!`); 191 + await this.loadSecrets(); 192 + } catch (error) { 193 + this.showError(null, `Failed to delete secret: ${error.message}`); 194 + } 195 + } 196 + 197 + showCreateForm() { 198 + document.getElementById('createSection').style.display = 'block'; 199 + document.getElementById('secretName').focus(); 200 + document.getElementById('createSection').scrollIntoView({ behavior: 'smooth' }); 201 + } 202 + 203 + hideCreateForm() { 204 + document.getElementById('createSection').style.display = 'none'; 205 + document.getElementById('createForm').reset(); 206 + document.getElementById('createMessage').innerHTML = ''; 207 + 208 + // Reset key-value pairs to single empty pair 209 + const kvPairs = document.getElementById('kvPairs'); 210 + kvPairs.innerHTML = ''; 211 + this.kvPairCounter = 0; 212 + this.addKeyValuePair(); // Add one empty pair 213 + } 214 + 215 + hideViewSection() { 216 + document.getElementById('viewSection').style.display = 'none'; 217 + document.getElementById('viewMessage').innerHTML = ''; 218 + } 219 + 220 + addKeyValuePair(key = '', value = '') { 221 + const kvPairs = document.getElementById('kvPairs'); 222 + 223 + const pairId = this.kvPairCounter++; 224 + const pairDiv = document.createElement('div'); 225 + pairDiv.className = 'kv-pair'; 226 + pairDiv.id = `kvPair${pairId}`; 227 + 228 + pairDiv.innerHTML = ` 229 + <input type="text" name="key${pairId}" placeholder="Key (e.g., api_key)" value="${this.escapeHtml(key)}" required> 230 + <input type="text" name="value${pairId}" placeholder="Value" value="${this.escapeHtml(value)}" required> 231 + <button type="button" class="btn btn-remove btn-small" onclick="ui.removeKeyValuePair('kvPair${pairId}')" title="Remove this key-value pair"> 232 + 233 + </button> 234 + `; 235 + 236 + kvPairs.appendChild(pairDiv); 237 + 238 + // Focus on the key input for new pairs (but not during initial load) 239 + if (!key && kvPairs.children.length > 1) { 240 + pairDiv.querySelector('input[name^="key"]').focus(); 241 + } 242 + } 243 + 244 + removeKeyValuePair(pairId) { 245 + const kvPairs = document.getElementById('kvPairs'); 246 + const pairElement = document.getElementById(pairId); 247 + 248 + // Don't allow removing the last pair 249 + if (kvPairs.children.length <= 1) { 250 + return; 251 + } 252 + 253 + if (pairElement) { 254 + pairElement.remove(); 255 + } 256 + } 257 + 258 + collectKeyValuePairs() { 259 + const kvPairs = document.getElementById('kvPairs'); 260 + const pairs = kvPairs.querySelectorAll('.kv-pair'); 261 + const data = {}; 262 + 263 + for (const pair of pairs) { 264 + const keyInput = pair.querySelector('input[name^="key"]'); 265 + const valueInput = pair.querySelector('input[name^="value"]'); 266 + 267 + if (keyInput && valueInput) { 268 + const key = keyInput.value.trim(); 269 + const value = valueInput.value.trim(); 270 + 271 + if (key && value) { 272 + data[key] = value; 273 + } 274 + } 275 + } 276 + 277 + return data; 278 + } 279 + 280 + async handleCreateSecret(event) { 281 + event.preventDefault(); 282 + 283 + const messageElement = document.getElementById('createMessage'); 284 + const formData = new FormData(event.target); 285 + const secretName = formData.get('secretName').trim(); 286 + 287 + messageElement.innerHTML = ''; 288 + 289 + // Validate inputs 290 + if (!secretName) { 291 + this.showError(messageElement, 'Secret name is required'); 292 + return; 293 + } 294 + 295 + // Collect key-value pairs 296 + const secretData = this.collectKeyValuePairs(); 297 + 298 + if (Object.keys(secretData).length === 0) { 299 + this.showError(messageElement, 'At least one key-value pair is required'); 300 + return; 301 + } 302 + 303 + // Validate key names (no spaces, no special chars except underscore) 304 + for (const key of Object.keys(secretData)) { 305 + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { 306 + this.showError(messageElement, `Invalid key "${key}". Keys must start with a letter and contain only letters, numbers, and underscores.`); 307 + return; 308 + } 309 + } 310 + 311 + try { 312 + // Show loading state 313 + const submitBtn = event.target.querySelector('button[type="submit"]'); 314 + const originalText = submitBtn.textContent; 315 + submitBtn.textContent = 'Creating...'; 316 + submitBtn.disabled = true; 317 + 318 + await this.api.createSecret(secretName, secretData); 319 + 320 + this.showSuccess(messageElement, `Secret "${secretName}" created successfully!`); 321 + 322 + // Reset form and refresh list 323 + event.target.reset(); 324 + await this.loadSecrets(); 325 + 326 + // Hide form after a delay 327 + setTimeout(() => this.hideCreateForm(), 2000); 328 + 329 + } catch (error) { 330 + this.showError(messageElement, `Failed to create secret: ${error.message}`); 331 + } finally { 332 + // Restore button state 333 + const submitBtn = event.target.querySelector('button[type="submit"]'); 334 + submitBtn.textContent = 'Create Secret'; 335 + submitBtn.disabled = false; 336 + } 337 + } 338 + 339 + async refreshSecrets() { 340 + await this.loadSecrets(); 341 + } 342 + 343 + showError(element, message) { 344 + const errorHTML = `<div class="error">❌ ${this.escapeHtml(message)}</div>`; 345 + if (element) { 346 + element.innerHTML = errorHTML; 347 + } else { 348 + // Show at top of page 349 + const container = document.querySelector('.container'); 350 + const existingError = container.querySelector('.error'); 351 + if (existingError) { 352 + existingError.remove(); 353 + } 354 + container.insertAdjacentHTML('afterbegin', errorHTML); 355 + 356 + // Remove after 5 seconds 357 + setTimeout(() => { 358 + const errorEl = container.querySelector('.error'); 359 + if (errorEl) errorEl.remove(); 360 + }, 5000); 361 + } 362 + } 363 + 364 + showSuccess(element, message) { 365 + const successHTML = `<div class="success">✅ ${this.escapeHtml(message)}</div>`; 366 + if (element) { 367 + element.innerHTML = successHTML; 368 + } else { 369 + // Show at top of page 370 + const container = document.querySelector('.container'); 371 + const existingSuccess = container.querySelector('.success'); 372 + if (existingSuccess) { 373 + existingSuccess.remove(); 374 + } 375 + container.insertAdjacentHTML('afterbegin', successHTML); 376 + 377 + // Remove after 5 seconds 378 + setTimeout(() => { 379 + const successEl = container.querySelector('.success'); 380 + if (successEl) successEl.remove(); 381 + }, 5000); 382 + } 383 + } 384 + 385 + escapeHtml(unsafe) { 386 + return unsafe 387 + .replace(/&/g, "&amp;") 388 + .replace(/</g, "&lt;") 389 + .replace(/>/g, "&gt;") 390 + .replace(/"/g, "&quot;") 391 + .replace(/'/g, "&#039;"); 392 + } 393 + } 394 + 395 + // Global functions for onclick handlers 396 + let ui; 397 + 398 + window.addEventListener('DOMContentLoaded', () => { 399 + ui = new HSMSecretsUI(); 400 + }); 401 + 402 + // Expose functions globally for onclick handlers 403 + window.refreshSecrets = () => ui.refreshSecrets(); 404 + window.showCreateForm = () => ui.showCreateForm(); 405 + window.hideCreateForm = () => ui.hideCreateForm(); 406 + window.hideViewSection = () => ui.hideViewSection();
+390
web/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>HSM Secrets Manager</title> 7 + <style> 8 + * { 9 + box-sizing: border-box; 10 + margin: 0; 11 + padding: 0; 12 + } 13 + 14 + body { 15 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 16 + background-color: #f5f5f5; 17 + color: #333; 18 + } 19 + 20 + .container { 21 + max-width: 1200px; 22 + margin: 0 auto; 23 + padding: 20px; 24 + } 25 + 26 + .header { 27 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 28 + color: white; 29 + padding: 30px; 30 + border-radius: 10px; 31 + margin-bottom: 30px; 32 + text-align: center; 33 + } 34 + 35 + .header h1 { 36 + font-size: 2.5em; 37 + margin-bottom: 10px; 38 + } 39 + 40 + .header p { 41 + font-size: 1.1em; 42 + opacity: 0.9; 43 + } 44 + 45 + .section { 46 + background: white; 47 + border-radius: 10px; 48 + padding: 25px; 49 + margin-bottom: 25px; 50 + box-shadow: 0 2px 10px rgba(0,0,0,0.1); 51 + } 52 + 53 + .section h2 { 54 + color: #333; 55 + margin-bottom: 20px; 56 + font-size: 1.5em; 57 + border-bottom: 2px solid #667eea; 58 + padding-bottom: 10px; 59 + } 60 + 61 + .form-group { 62 + margin-bottom: 20px; 63 + } 64 + 65 + .form-group label { 66 + display: block; 67 + margin-bottom: 5px; 68 + font-weight: 600; 69 + color: #555; 70 + } 71 + 72 + .form-group input, .form-group textarea { 73 + width: 100%; 74 + padding: 12px; 75 + border: 2px solid #e1e5e9; 76 + border-radius: 6px; 77 + font-size: 14px; 78 + transition: border-color 0.3s ease; 79 + } 80 + 81 + .form-group input:focus, .form-group textarea:focus { 82 + outline: none; 83 + border-color: #667eea; 84 + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); 85 + } 86 + 87 + .form-group textarea { 88 + resize: vertical; 89 + min-height: 120px; 90 + font-family: Monaco, 'Cascadia Code', Consolas, monospace; 91 + } 92 + 93 + .kv-pair { 94 + display: flex; 95 + gap: 10px; 96 + margin-bottom: 10px; 97 + align-items: center; 98 + } 99 + 100 + .kv-pair input[type="text"] { 101 + flex: 1; 102 + } 103 + 104 + .kv-pair input[name*="key"] { 105 + flex: 0 0 30%; 106 + } 107 + 108 + .kv-pair input[name*="value"] { 109 + flex: 0 0 60%; 110 + } 111 + 112 + .btn-small { 113 + padding: 0; 114 + font-size: 14px; 115 + width: 28px; 116 + height: 28px; 117 + display: flex; 118 + align-items: center; 119 + justify-content: center; 120 + line-height: 1; 121 + } 122 + 123 + .btn-add { 124 + background: #38a169; 125 + } 126 + 127 + .btn-add:hover { 128 + background: #2f855a; 129 + box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3); 130 + } 131 + 132 + .btn-remove { 133 + background: #e53e3e; 134 + } 135 + 136 + .btn-remove:hover { 137 + background: #c53030; 138 + box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 139 + } 140 + 141 + .kv-container { 142 + border: 2px dashed #e2e8f0; 143 + border-radius: 8px; 144 + padding: 15px; 145 + background: #f8f9fa; 146 + } 147 + 148 + .kv-header { 149 + display: flex; 150 + justify-content: space-between; 151 + align-items: center; 152 + margin-bottom: 15px; 153 + font-weight: 600; 154 + color: #4a5568; 155 + } 156 + 157 + .add-first-pair { 158 + text-align: center; 159 + color: #666; 160 + padding: 20px; 161 + } 162 + 163 + .btn { 164 + background: #667eea; 165 + color: white; 166 + border: none; 167 + padding: 20px 20px; 168 + border-radius: 6px; 169 + cursor: pointer; 170 + font-size: 14px; 171 + font-weight: 600; 172 + transition: all 0.3s ease; 173 + text-decoration: none; 174 + } 175 + 176 + .btn:hover { 177 + background: #5a67d8; 178 + transform: translateY(-2px); 179 + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); 180 + } 181 + 182 + .btn-danger { 183 + background: #e53e3e; 184 + } 185 + 186 + .btn-danger:hover { 187 + background: #c53030; 188 + box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 189 + } 190 + 191 + .btn-secondary { 192 + background: #718096; 193 + } 194 + 195 + .btn-secondary:hover { 196 + background: #4a5568; 197 + box-shadow: 0 4px 12px rgba(113, 128, 150, 0.3); 198 + } 199 + 200 + .secrets-list { 201 + margin-top: 20px; 202 + } 203 + 204 + .secret-item { 205 + background: #f8f9fa; 206 + border: 1px solid #e2e8f0; 207 + border-radius: 6px; 208 + padding: 15px; 209 + margin-bottom: 10px; 210 + display: flex; 211 + justify-content: between; 212 + align-items: center; 213 + } 214 + 215 + .secret-name { 216 + font-weight: 600; 217 + color: #2d3748; 218 + flex-grow: 1; 219 + } 220 + 221 + .secret-actions { 222 + display: flex; 223 + gap: 10px; 224 + } 225 + 226 + .loading { 227 + text-align: center; 228 + padding: 20px; 229 + color: #666; 230 + } 231 + 232 + .error { 233 + background: #fed7d7; 234 + border: 1px solid #feb2b2; 235 + color: #c53030; 236 + padding: 15px; 237 + border-radius: 6px; 238 + margin-bottom: 20px; 239 + } 240 + 241 + .success { 242 + background: #c6f6d5; 243 + border: 1px solid #9ae6b4; 244 + color: #22543d; 245 + padding: 15px; 246 + border-radius: 6px; 247 + margin-bottom: 20px; 248 + } 249 + 250 + .json-preview { 251 + background: #1a202c; 252 + color: #e2e8f0; 253 + padding: 15px; 254 + border-radius: 6px; 255 + font-family: Monaco, 'Cascadia Code', Consolas, monospace; 256 + font-size: 12px; 257 + white-space: pre-wrap; 258 + max-height: 300px; 259 + overflow-y: auto; 260 + margin-top: 10px; 261 + } 262 + 263 + .stats { 264 + display: grid; 265 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 266 + gap: 20px; 267 + margin-bottom: 30px; 268 + } 269 + 270 + .stat-card { 271 + background: white; 272 + padding: 20px; 273 + border-radius: 10px; 274 + text-align: center; 275 + box-shadow: 0 2px 10px rgba(0,0,0,0.1); 276 + } 277 + 278 + .stat-number { 279 + font-size: 2em; 280 + font-weight: bold; 281 + color: #667eea; 282 + margin-bottom: 5px; 283 + } 284 + 285 + .stat-label { 286 + color: #666; 287 + font-size: 0.9em; 288 + } 289 + 290 + .toolbar { 291 + display: flex; 292 + gap: 10px; 293 + margin-bottom: 20px; 294 + align-items: center; 295 + } 296 + 297 + @media (max-width: 768px) { 298 + .container { 299 + padding: 10px; 300 + } 301 + 302 + .header h1 { 303 + font-size: 1.8em; 304 + } 305 + 306 + .secret-item { 307 + flex-direction: column; 308 + align-items: stretch; 309 + } 310 + 311 + .secret-actions { 312 + margin-top: 10px; 313 + justify-content: flex-end; 314 + } 315 + } 316 + </style> 317 + </head> 318 + <body> 319 + <div class="container"> 320 + <div class="header"> 321 + <h1>🔐 HSM Secrets Manager</h1> 322 + <p>Manage your Hardware Security Module secrets through a simple web interface</p> 323 + </div> 324 + 325 + <div class="stats" id="stats"> 326 + <div class="stat-card"> 327 + <div class="stat-number" id="totalSecrets">-</div> 328 + <div class="stat-label">Total Secrets</div> 329 + </div> 330 + <div class="stat-card"> 331 + <div class="stat-number" id="apiStatus">-</div> 332 + <div class="stat-label">API Status</div> 333 + </div> 334 + </div> 335 + 336 + <div class="section"> 337 + <h2>📋 Secrets List</h2> 338 + <div class="toolbar"> 339 + <button class="btn btn-secondary" onclick="refreshSecrets()">🔄 Refresh</button> 340 + <button class="btn" onclick="showCreateForm()">➕ Create New Secret</button> 341 + </div> 342 + <div id="secretsList" class="secrets-list"> 343 + <div class="loading">Loading secrets...</div> 344 + </div> 345 + </div> 346 + 347 + <div class="section" id="createSection" style="display: none;"> 348 + <h2>➕ Create New Secret</h2> 349 + <div id="createMessage"></div> 350 + <form id="createForm"> 351 + <div class="form-group"> 352 + <label for="secretName">Secret Name</label> 353 + <input type="text" id="secretName" name="secretName" placeholder="e.g., my-app-secrets" required> 354 + </div> 355 + 356 + <div class="form-group"> 357 + <label>Secret Data</label> 358 + <div class="kv-container"> 359 + <div class="kv-header"> 360 + <span>Key-Value Pairs</span> 361 + <button type="button" class="btn btn-add btn-small" onclick="ui.addKeyValuePair()" title="Add new key-value pair"> 362 + 363 + </button> 364 + </div> 365 + <div id="kvPairs"> 366 + <!-- Initial key-value pair will be added by JavaScript --> 367 + </div> 368 + </div> 369 + </div> 370 + 371 + <div style="display: flex; gap: 10px;"> 372 + <button type="submit" class="btn">Create Secret</button> 373 + <button type="button" class="btn btn-secondary" onclick="hideCreateForm()">Cancel</button> 374 + </div> 375 + </form> 376 + </div> 377 + 378 + <div class="section" id="viewSection" style="display: none;"> 379 + <h2>🔍 Secret Details</h2> 380 + <div id="viewMessage"></div> 381 + <div id="secretDetails"></div> 382 + <div style="margin-top: 20px;"> 383 + <button class="btn btn-secondary" onclick="hideViewSection()">Close</button> 384 + </div> 385 + </div> 386 + </div> 387 + 388 + <script src="app.js"></script> 389 + </body> 390 + </html>