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.

update plugin for better --from-file support. add completion, general bug fixes

+382 -43
+149 -3
kubectl-hsm/README.md
··· 11 11 - **Kubernetes-native**: Works seamlessly with kubectl and respects namespace context 12 12 - **Secure**: All secrets stored in HSM hardware for maximum security 13 13 - **Interactive**: Support for secure password input and interactive secret creation 14 - - **Flexible**: Multiple input methods (literals, files, interactive prompts) 14 + - **Flexible**: Multiple input methods (literals, files, JSON import, interactive prompts) 15 + - **Simple naming**: Uses filenames (without extension) as secret keys - just add `.txt` for clean domain names 16 + - **Collision detection**: Warns when file imports would overwrite existing keys 17 + - **Bulk import**: JSON file support for migrating secrets from other systems 18 + - **Shell completion**: Tab completion for commands, flags, and secret names (bash/zsh/fish/powershell) 15 19 - **Cross-platform**: Supports Linux, macOS, and Windows 16 20 17 21 ## Installation ··· 38 42 make install 39 43 ``` 40 44 45 + ## Shell Completion 46 + 47 + The plugin supports bash, zsh, fish, and PowerShell completion for commands, flags, and secret names. 48 + 49 + ### Bash Completion 50 + 51 + ```bash 52 + # Load completions in your current shell session 53 + source <(kubectl hsm completion bash) 54 + 55 + # Install completions for all sessions 56 + ## Linux: 57 + kubectl hsm completion bash > /etc/bash_completion.d/kubectl-hsm 58 + 59 + ## macOS: 60 + kubectl hsm completion bash > $(brew --prefix)/etc/bash_completion.d/kubectl-hsm 61 + 62 + # Restart your shell 63 + ``` 64 + 65 + ### Zsh Completion 66 + 67 + ```bash 68 + # Load completions in your current shell session 69 + source <(kubectl hsm completion zsh) 70 + 71 + # Install completions for all sessions 72 + ## Using zsh completion system: 73 + kubectl hsm completion zsh > "${fpath[1]}/_kubectl-hsm" 74 + 75 + ## Using oh-my-zsh: 76 + mkdir -p ~/.oh-my-zsh/completions 77 + kubectl hsm completion zsh > ~/.oh-my-zsh/completions/_kubectl-hsm 78 + 79 + # Restart your shell 80 + ``` 81 + 82 + ### Features 83 + 84 + Completion provides: 85 + - **Command completion**: `kubectl hsm <TAB>` shows available commands 86 + - **Secret name completion**: `kubectl hsm get <TAB>` shows your actual secrets 87 + - **Flag completion**: `kubectl hsm get --<TAB>` shows available flags 88 + - **Value completion**: `kubectl hsm get secret -o <TAB>` shows `text`, `json`, `yaml` 89 + 41 90 ## Prerequisites 42 91 43 92 - kubectl installed and configured ··· 66 115 --from-literal api_key=sk_test_123 \ 67 116 --from-literal endpoint=https://api.example.com 68 117 69 - # Load secret values from files 118 + # Load secret values from files (explicit key names) 70 119 kubectl hsm create tls-cert \ 71 120 --from-file cert=server.crt \ 72 121 --from-file key=server.key 73 122 123 + # Load files using filename as key (extensions removed) 124 + kubectl hsm create dns-config \ 125 + --from-file ./example.com.txt \ 126 + --from-file ./j5t.io.txt \ 127 + --from-file ./config.txt 128 + 129 + # Load from JSON file (bulk import) 130 + kubectl hsm create my-secrets --from-json-file=secrets.json 131 + 74 132 # Get a secret (shows metadata, not values) 75 133 kubectl hsm get database-creds 76 134 ··· 123 181 124 182 This will prompt you for each key-value pair. Fields that look like passwords (containing "password", "secret", "token", or "key") will hide input for security. 125 183 184 + ### File Loading Options 185 + 186 + The plugin supports several ways to load secrets from files: 187 + 188 + #### 1. Explicit Key Names (Recommended) 189 + ```bash 190 + # Specify both key name and file path 191 + kubectl hsm create tls-cert \ 192 + --from-file cert=./server.crt \ 193 + --from-file key=./private.key 194 + ``` 195 + 196 + #### 2. Filename as Key 197 + ```bash 198 + # Uses filename (without extension) as key name 199 + kubectl hsm create config --from-file ./database.conf 200 + # Creates key "database" with contents of database.conf 201 + 202 + # Extensions are removed from filename to create key 203 + kubectl hsm create dns-zones \ 204 + --from-file ./example.com.txt \ 205 + --from-file ./j5t.io.txt 206 + # Creates keys "example.com" and "j5t.io" (extension .txt removed) 207 + ``` 208 + 209 + #### 3. JSON Import (New!) 210 + ```bash 211 + # Import from structured JSON file 212 + kubectl hsm create bulk-secrets --from-json-file=import.json 213 + ``` 214 + 215 + **JSON format:** 216 + ```json 217 + { 218 + "name": "secret-name-in-file", 219 + "secrets": [ 220 + {"key": "api_key", "value": "sk_123"}, 221 + {"key": "endpoint", "value": "https://api.example.com"}, 222 + {"key": "config.yaml", "value": "server:\n port: 8080"} 223 + ] 224 + } 225 + ``` 226 + 227 + #### Key Collision Detection 228 + When using multiple `--from-file` arguments, the plugin warns about key conflicts: 229 + ```bash 230 + kubectl hsm create test --from-file ./config.txt --from-file ./backup/config.txt 231 + # Warning: Key 'config' already exists, overwriting with value from './backup/config.txt' 232 + ``` 233 + 126 234 ## How It Works 127 235 128 236 1. **Service Discovery**: The plugin automatically discovers the HSM operator API service in your current namespace ··· 196 304 ### TLS Certificates 197 305 198 306 ```bash 199 - # Load certificate files 307 + # Method 1: Explicit key names (recommended) 200 308 kubectl hsm create tls-server \ 201 309 --from-file tls.crt=server.crt \ 202 310 --from-file tls.key=server.key \ 203 311 --from-file ca.crt=ca.crt 204 312 313 + # Method 2: Use filenames as keys (extension removed) 314 + kubectl hsm create tls-domains \ 315 + --from-file ./example.com.crt \ 316 + --from-file ./example.com.key \ 317 + --from-file ./api.example.com.crt 318 + # Creates keys: "example.com", "example.com", "api.example.com" (extensions removed) 319 + 205 320 # Check certificate info 206 321 kubectl hsm get tls-server 322 + ``` 323 + 324 + ### DNS Configuration (New Use Case) 325 + 326 + Perfect for managing DNS zone files with domain names as keys: 327 + 328 + ```bash 329 + # Load DNS zone files - use .txt extension for clean keys 330 + kubectl hsm create dns-zones \ 331 + --from-file ./j5t.io.txt \ 332 + --from-file ./example.com.txt \ 333 + --from-file ./internal.net.txt 334 + 335 + # Results in keys: "j5t.io", "example.com", "internal.net" 336 + kubectl hsm get dns-zones 337 + ``` 338 + 339 + ### Bulk Import from JSON 340 + 341 + ```bash 342 + # Import multiple secrets from JSON (useful for migrations) 343 + kubectl hsm create imported-secrets --from-json-file=bitwarden-export.json 344 + 345 + # JSON structure matches your export format: 346 + # { 347 + # "name": "dns-config", 348 + # "secrets": [ 349 + # {"key": "j5t.io", "value": "@ IN SOA ns.j5t.io..."}, 350 + # {"key": "jarrett.net", "value": "@ IN SOA ns.jarrett.net..."} 351 + # ] 352 + # } 207 353 ``` 208 354 209 355 ## Troubleshooting
kubectl-hsm/bin/kubectl-hsm

This is a binary file and will not be displayed.

+81
kubectl-hsm/cmd/main.go
··· 83 83 // Add operational commands 84 84 cmd.AddCommand(commands.NewHealthCmd()) 85 85 86 + // Add completion command 87 + cmd.AddCommand(newCompletionCmd()) 88 + 86 89 return cmd 87 90 } 88 91 ··· 97 100 fmt.Printf("Plugin type: kubectl plugin\n") 98 101 }, 99 102 } 103 + } 104 + 105 + func newCompletionCmd() *cobra.Command { 106 + cmd := &cobra.Command{ 107 + Use: "completion [bash|zsh|fish|powershell]", 108 + Short: "Generate completion script", 109 + Long: `Generate the autocompletion script for kubectl-hsm for the specified shell. 110 + See each sub-command's help for details on how to use the generated script.`, 111 + DisableFlagsInUseLine: true, 112 + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, 113 + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 114 + RunE: func(cmd *cobra.Command, args []string) error { 115 + switch args[0] { 116 + case "bash": 117 + return cmd.Root().GenBashCompletion(os.Stdout) 118 + case "zsh": 119 + return cmd.Root().GenZshCompletion(os.Stdout) 120 + case "fish": 121 + return cmd.Root().GenFishCompletion(os.Stdout, true) 122 + case "powershell": 123 + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) 124 + default: 125 + return fmt.Errorf("unsupported shell type: %s", args[0]) 126 + } 127 + }, 128 + } 129 + 130 + // Add subcommands for each shell 131 + cmd.AddCommand(&cobra.Command{ 132 + Use: "bash", 133 + Short: "Generate the autocompletion script for bash", 134 + Long: `Generate the autocompletion script for the bash shell. 135 + 136 + To load completions in your current shell session: 137 + 138 + source <(kubectl hsm completion bash) 139 + 140 + To load completions for every new session, execute once: 141 + 142 + ### Linux: 143 + kubectl hsm completion bash > /etc/bash_completion.d/kubectl-hsm 144 + 145 + ### macOS: 146 + kubectl hsm completion bash > $(brew --prefix)/etc/bash_completion.d/kubectl-hsm 147 + 148 + You will need to start a new shell for this setup to take effect.`, 149 + DisableFlagsInUseLine: true, 150 + RunE: func(cmd *cobra.Command, args []string) error { 151 + return cmd.Root().GenBashCompletion(os.Stdout) 152 + }, 153 + }) 154 + 155 + cmd.AddCommand(&cobra.Command{ 156 + Use: "zsh", 157 + Short: "Generate the autocompletion script for zsh", 158 + Long: `Generate the autocompletion script for the zsh shell. 159 + 160 + To load completions in your current shell session: 161 + 162 + source <(kubectl hsm completion zsh) 163 + 164 + To load completions for every new session, execute once: 165 + 166 + ### zsh completion system: 167 + kubectl hsm completion zsh > "${fpath[1]}/_kubectl-hsm" 168 + 169 + ### Using oh-my-zsh: 170 + mkdir -p ~/.oh-my-zsh/completions 171 + kubectl hsm completion zsh > ~/.oh-my-zsh/completions/_kubectl-hsm 172 + 173 + You will need to start a new shell for this setup to take effect.`, 174 + DisableFlagsInUseLine: true, 175 + RunE: func(cmd *cobra.Command, args []string) error { 176 + return cmd.Root().GenZshCompletion(os.Stdout) 177 + }, 178 + }) 179 + 180 + return cmd 100 181 }
+99 -33
kubectl-hsm/pkg/commands/common.go
··· 18 18 19 19 import ( 20 20 "context" 21 + "encoding/json" 21 22 "fmt" 22 23 "os" 23 24 "path/filepath" 24 25 "strings" 25 26 "syscall" 26 - "time" 27 27 28 + "github.com/spf13/cobra" 28 29 "golang.org/x/term" 29 30 30 31 "github.com/evanjarrett/hsm-secrets-operator/kubectl-hsm/pkg/client" ··· 130 131 return data, nil 131 132 } 132 133 133 - // readFromFile reads content from a file for --from-file flags 134 - func readFromFile(key, filename string) (map[string]any, error) { 135 - // Handle both "key=file" and "file" formats 136 - if filename == "" { 137 - filename = key 138 - key = filepath.Base(filename) 139 - // Remove file extension for the key 140 - if ext := filepath.Ext(key); ext != "" { 141 - key = strings.TrimSuffix(key, ext) 142 - } 143 - } 144 - 145 - content, err := os.ReadFile(filename) 146 - if err != nil { 147 - return nil, fmt.Errorf("failed to read file %s: %w", filename, err) 148 - } 149 - 150 - data := map[string]any{ 151 - key: string(content), 152 - } 153 - 154 - return data, nil 155 - } 156 134 157 135 // promptForInteractiveInput prompts the user for secret values interactively 158 136 func promptForInteractiveInput() (map[string]any, error) { ··· 191 169 return data, nil 192 170 } 193 171 194 - // formatDuration formats a time duration in a human-readable way 195 - func formatDuration(d time.Duration) string { 196 - if d < time.Minute { 197 - return fmt.Sprintf("%ds ago", int(d.Seconds())) 172 + // JsonSecretImport represents the structure of a JSON secret import file 173 + type JsonSecretImport struct { 174 + Name string `json:"name"` 175 + Secrets []JsonSecretKVPair `json:"secrets"` 176 + } 177 + 178 + // JsonSecretKVPair represents a key-value pair in the JSON import 179 + type JsonSecretKVPair struct { 180 + Key string `json:"key"` 181 + Value string `json:"value"` 182 + } 183 + 184 + // readFromJsonFile reads secret data from a JSON file 185 + func readFromJsonFile(filename string) (map[string]any, error) { 186 + content, err := os.ReadFile(filename) 187 + if err != nil { 188 + return nil, fmt.Errorf("failed to read JSON file %s: %w", filename, err) 198 189 } 199 - if d < time.Hour { 200 - return fmt.Sprintf("%dm ago", int(d.Minutes())) 190 + 191 + var importData JsonSecretImport 192 + if err := json.Unmarshal(content, &importData); err != nil { 193 + return nil, fmt.Errorf("failed to parse JSON file %s: %w", filename, err) 201 194 } 202 - if d < 24*time.Hour { 203 - return fmt.Sprintf("%dh ago", int(d.Hours())) 195 + 196 + data := make(map[string]any) 197 + for _, kv := range importData.Secrets { 198 + if kv.Key == "" { 199 + return nil, fmt.Errorf("empty key found in JSON file %s", filename) 200 + } 201 + data[kv.Key] = kv.Value 204 202 } 205 - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) 203 + 204 + return data, nil 205 + } 206 + 207 + // readFromFileImproved reads from file with improved key derivation logic 208 + func readFromFileImproved(key, filename string) (map[string]any, error) { 209 + // Handle both "key=file" and "file" formats 210 + if key == "" { 211 + // Only filename provided (no explicit key) 212 + key = filepath.Base(filename) 213 + 214 + // Remove file extension for the key 215 + if ext := filepath.Ext(key); ext != "" { 216 + key = strings.TrimSuffix(key, ext) 217 + } 218 + } 219 + 220 + content, err := os.ReadFile(filename) 221 + if err != nil { 222 + return nil, fmt.Errorf("failed to read file %s: %w", filename, err) 223 + } 224 + 225 + data := map[string]any{ 226 + key: string(content), 227 + } 228 + 229 + return data, nil 230 + } 231 + 232 + // truncateString truncates a string to the specified length with "..." suffix 233 + func truncateString(s string, maxLen int) string { 234 + if len(s) <= maxLen { 235 + return s 236 + } 237 + return s[:maxLen-3] + "..." 238 + } 239 + 240 + // CompletionSecretNames provides bash completion for secret names 241 + func CompletionSecretNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 242 + // If we already have a secret name, don't complete more 243 + if len(args) >= 1 { 244 + return nil, cobra.ShellCompDirectiveNoFileComp 245 + } 246 + 247 + // Get namespace from flag or use default 248 + namespace, _ := cmd.Flags().GetString("namespace") 249 + verbose, _ := cmd.Flags().GetBool("verbose") 250 + 251 + // Create client manager 252 + cm, err := NewClientManager(namespace, verbose) 253 + if err != nil { 254 + return nil, cobra.ShellCompDirectiveError 255 + } 256 + defer cm.Close() 257 + 258 + // Get HSM client 259 + ctx := cmd.Context() 260 + hsmClient, err := cm.GetClient(ctx) 261 + if err != nil { 262 + return nil, cobra.ShellCompDirectiveError 263 + } 264 + 265 + // List secrets 266 + secretList, err := hsmClient.ListSecrets(ctx, 0, 0) 267 + if err != nil { 268 + return nil, cobra.ShellCompDirectiveError 269 + } 270 + 271 + return secretList.Secrets, cobra.ShellCompDirectiveNoFileComp 206 272 }
+25 -5
kubectl-hsm/pkg/commands/create.go
··· 29 29 CommonOptions 30 30 FromLiteral []string 31 31 FromFile []string 32 + FromJsonFile string 32 33 Interactive bool 33 34 } 34 35 ··· 62 63 } 63 64 64 65 cmd.Flags().StringArrayVar(&opts.FromLiteral, "from-literal", nil, "Specify a key and literal value to insert in secret (i.e. --from-literal key=value)") 65 - cmd.Flags().StringArrayVar(&opts.FromFile, "from-file", nil, "Key files can be specified using their file path, in which case a default name will be given to them, or optionally with a name and file path, in which case the given name will be used") 66 + cmd.Flags().StringArrayVar(&opts.FromFile, "from-file", nil, "Load secret data from files. Use 'key=file' or just 'file' (uses filename without extension as key)") 67 + cmd.Flags().StringVar(&opts.FromJsonFile, "from-json-file", "", "Load secret data from a JSON file with structure {\"name\":\"secret-name\",\"secrets\":[{\"key\":\"k\",\"value\":\"v\"}]}") 66 68 cmd.Flags().BoolVar(&opts.Interactive, "interactive", false, "Prompt for secret values interactively") 67 69 cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Override the default namespace") 68 70 cmd.Flags().StringVarP(&opts.Output, "output", "o", "text", "Output format (text, json, yaml)") 69 71 cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") 70 72 73 + // Add completion for output flag 74 + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 75 + return []string{"text", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp 76 + }) 77 + 71 78 return cmd 72 79 } 73 80 ··· 86 93 if len(opts.FromFile) > 0 { 87 94 methods++ 88 95 } 96 + if opts.FromJsonFile != "" { 97 + methods++ 98 + } 89 99 if opts.Interactive { 90 100 methods++ 91 101 } 92 102 93 103 if methods == 0 { 94 - return fmt.Errorf("must specify one of --from-literal, --from-file, or --interactive") 104 + return fmt.Errorf("must specify one of --from-literal, --from-file, --from-json-file, or --interactive") 95 105 } 96 106 if methods > 1 { 97 - return fmt.Errorf("cannot specify multiple input methods (--from-literal, --from-file, --interactive)") 107 + return fmt.Errorf("cannot specify multiple input methods (--from-literal, --from-file, --from-json-file, --interactive)") 98 108 } 99 109 100 110 // Collect secret data ··· 118 128 filename = parts[0] 119 129 } 120 130 121 - fileData, err := readFromFile(key, filename) 131 + fileData, err := readFromFileImproved(key, filename) 122 132 if err != nil { 123 133 return err 124 134 } 125 135 126 - // Merge file data 136 + // Merge file data with collision detection 127 137 for k, v := range fileData { 138 + if existingValue, exists := secretData[k]; exists { 139 + fmt.Printf("Warning: Key '%s' already exists (from previous file), overwriting with value from '%s'\n", k, filename) 140 + fmt.Printf(" Previous value: %s...\n", truncateString(existingValue.(string), 50)) 141 + fmt.Printf(" New value: %s...\n", truncateString(v.(string), 50)) 142 + } 128 143 secretData[k] = v 129 144 } 145 + } 146 + } else if opts.FromJsonFile != "" { 147 + secretData, err = readFromJsonFile(opts.FromJsonFile) 148 + if err != nil { 149 + return err 130 150 } 131 151 } else if opts.Interactive { 132 152 secretData, err = promptForInteractiveInput()
+3
kubectl-hsm/pkg/commands/delete.go
··· 64 64 cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Override the default namespace") 65 65 cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") 66 66 67 + // Add completion for secret names 68 + cmd.ValidArgsFunction = CompletionSecretNames 69 + 67 70 return cmd 68 71 } 69 72
+8
kubectl-hsm/pkg/commands/get.go
··· 68 68 cmd.Flags().StringVarP(&opts.Output, "output", "o", "text", "Output format (text, json, yaml)") 69 69 cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") 70 70 71 + // Add completion for secret names 72 + cmd.ValidArgsFunction = CompletionSecretNames 73 + 74 + // Add completion for output flag 75 + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 76 + return []string{"text", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp 77 + }) 78 + 71 79 return cmd 72 80 } 73 81
+17 -2
kubectl-hsm/pkg/commands/list.go
··· 69 69 cmd.Flags().StringVarP(&opts.Output, "output", "o", "text", "Output format (text, json, yaml)") 70 70 cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") 71 71 72 + // Add completion for output flag 73 + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 74 + return []string{"text", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp 75 + }) 76 + 72 77 return cmd 73 78 } 74 79 ··· 96 101 // Handle output formatting 97 102 switch opts.Output { 98 103 case "json": 99 - jsonBytes, err := json.MarshalIndent(secretList, "", " ") 104 + // Create clean output without pagination fields 105 + cleanOutput := map[string]interface{}{ 106 + "count": secretList.Count, 107 + "secrets": secretList.Secrets, 108 + } 109 + jsonBytes, err := json.MarshalIndent(cleanOutput, "", " ") 100 110 if err != nil { 101 111 return fmt.Errorf("failed to marshal secrets to JSON: %w", err) 102 112 } 103 113 fmt.Println(string(jsonBytes)) 104 114 case "yaml": 105 - yamlBytes, err := yaml.Marshal(secretList) 115 + // Create clean output without pagination fields 116 + cleanOutput := map[string]interface{}{ 117 + "count": secretList.Count, 118 + "secrets": secretList.Secrets, 119 + } 120 + yamlBytes, err := yaml.Marshal(cleanOutput) 106 121 if err != nil { 107 122 return fmt.Errorf("failed to marshal secrets to YAML: %w", err) 108 123 }