diff --git a/cmd/ssh.go b/cmd/ssh.go index 291c296..3f5dc9e 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -35,6 +35,19 @@ var sshAddKeyCmd = &cobra.Command{ RunE: runSSHAddKey, } +var sshRemoveKeyCmd = &cobra.Command{ + Use: "remove-key", + Short: "remove an ssh key", + Long: "remove an ssh key from your account", + PreRunE: func(cmd *cobra.Command, args []string) error { + if sshKeyName == "" && sshKeyFingerprint == "" { + return fmt.Errorf("either --fingerprint or --name must be provided") + } + return nil + }, + RunE: runSSHRemoveKey, +} + var sshInfoCmd = &cobra.Command{ Use: "info ", Short: "show ssh info for a pod", @@ -54,19 +67,24 @@ var sshConnectCmd = &cobra.Command{ } var ( - sshKeyFile string - sshKey string - sshVerbose bool + sshKeyFile string + sshKey string + sshKeyName string + sshKeyFingerprint string + sshVerbose bool ) func init() { sshCmd.AddCommand(sshListKeysCmd) sshCmd.AddCommand(sshAddKeyCmd) + sshCmd.AddCommand(sshRemoveKeyCmd) sshCmd.AddCommand(sshInfoCmd) sshCmd.AddCommand(sshConnectCmd) sshAddKeyCmd.Flags().StringVar(&sshKey, "key", "", "the public key to add") sshAddKeyCmd.Flags().StringVar(&sshKeyFile, "key-file", "", "file containing the public key") + sshRemoveKeyCmd.Flags().StringVar(&sshKeyFingerprint, "fingerprint", "", "fingerprint of the key to remove") + sshRemoveKeyCmd.Flags().StringVar(&sshKeyName, "name", "", "name of the key to remove") sshInfoCmd.Flags().BoolVarP(&sshVerbose, "verbose", "v", false, "include pod id and name in output") sshConnectCmd.Flags().BoolVarP(&sshVerbose, "verbose", "v", false, "include pod id and name in output") @@ -130,6 +148,20 @@ func runSSHAddKey(cmd *cobra.Command, args []string) error { return output.Print(map[string]interface{}{"added": true}, &output.Config{Format: format}) } +func runSSHRemoveKey(cmd *cobra.Command, args []string) error { + client, err := api.NewGraphQLClient() + if err != nil { + return err + } + + if err := client.RemovePublicSSHKey(sshKeyName, sshKeyFingerprint); err != nil { + return fmt.Errorf("failed to remove ssh key: %w", err) + } + + format := output.ParseFormat(cmd.Flag("output").Value.String()) + return output.Print(map[string]interface{}{"removed": true}, &output.Config{Format: format}) +} + func runSSHInfo(cmd *cobra.Command, args []string) error { return runSSHInfoWithArgs(cmd, args, false) } diff --git a/cmd/ssh_test.go b/cmd/ssh_test.go index 79ce9ac..f8a9ec4 100644 --- a/cmd/ssh_test.go +++ b/cmd/ssh_test.go @@ -48,8 +48,47 @@ func TestSSHCmd_HasInfoCommand(t *testing.T) { } } +func TestSSHCmd_HasRemoveKeyCommand(t *testing.T) { + found := false + for _, cmd := range sshCmd.Commands() { + if cmd.Use == "remove-key" { + found = true + break + } + } + if !found { + t.Error("expected ssh remove-key command to exist") + } +} + func TestSSHConnect_Hidden(t *testing.T) { if !sshConnectCmd.Hidden { t.Error("expected ssh connect to be hidden") } } + +func TestSSHRemoveKey_RequiresIdentifier(t *testing.T) { + origName := sshKeyName + origFingerprint := sshKeyFingerprint + t.Cleanup(func() { + sshKeyName = origName + sshKeyFingerprint = origFingerprint + }) + + sshKeyName = "" + sshKeyFingerprint = "" + if err := sshRemoveKeyCmd.PreRunE(sshRemoveKeyCmd, nil); err == nil { + t.Error("expected ssh remove-key to require an identifier") + } + + sshKeyName = "temp-key" + if err := sshRemoveKeyCmd.PreRunE(sshRemoveKeyCmd, nil); err != nil { + t.Errorf("unexpected error for name: %v", err) + } + + sshKeyName = "" + sshKeyFingerprint = "SHA256:test" + if err := sshRemoveKeyCmd.PreRunE(sshRemoveKeyCmd, nil); err != nil { + t.Errorf("unexpected error for fingerprint: %v", err) + } +} diff --git a/docs/runpodctl_ssh.md b/docs/runpodctl_ssh.md index 1737bfe..879ade4 100644 --- a/docs/runpodctl_ssh.md +++ b/docs/runpodctl_ssh.md @@ -24,5 +24,6 @@ manage ssh keys and show ssh info for pods. uses the api key from RUNPOD_API_KEY * [runpodctl ssh add-key](runpodctl_ssh_add-key.md) - add an ssh key * [runpodctl ssh info](runpodctl_ssh_info.md) - show ssh info for a pod * [runpodctl ssh list-keys](runpodctl_ssh_list-keys.md) - list all ssh keys +* [runpodctl ssh remove-key](runpodctl_ssh_remove-key.md) - remove an ssh key -###### Auto generated by spf13/cobra on 23-Mar-2026 +###### Auto generated by spf13/cobra on 8-Apr-2026 diff --git a/docs/runpodctl_ssh_add-key.md b/docs/runpodctl_ssh_add-key.md index feb3d52..463f570 100644 --- a/docs/runpodctl_ssh_add-key.md +++ b/docs/runpodctl_ssh_add-key.md @@ -28,4 +28,4 @@ runpodctl ssh add-key [flags] * [runpodctl ssh](runpodctl_ssh.md) - manage ssh keys and connections -###### Auto generated by spf13/cobra on 23-Mar-2026 +###### Auto generated by spf13/cobra on 8-Apr-2026 diff --git a/docs/runpodctl_ssh_info.md b/docs/runpodctl_ssh_info.md index 5ab8d59..65c9bff 100644 --- a/docs/runpodctl_ssh_info.md +++ b/docs/runpodctl_ssh_info.md @@ -27,4 +27,4 @@ runpodctl ssh info [flags] * [runpodctl ssh](runpodctl_ssh.md) - manage ssh keys and connections -###### Auto generated by spf13/cobra on 23-Mar-2026 +###### Auto generated by spf13/cobra on 8-Apr-2026 diff --git a/docs/runpodctl_ssh_list-keys.md b/docs/runpodctl_ssh_list-keys.md index 4523c9e..2130078 100644 --- a/docs/runpodctl_ssh_list-keys.md +++ b/docs/runpodctl_ssh_list-keys.md @@ -26,4 +26,4 @@ runpodctl ssh list-keys [flags] * [runpodctl ssh](runpodctl_ssh.md) - manage ssh keys and connections -###### Auto generated by spf13/cobra on 23-Mar-2026 +###### Auto generated by spf13/cobra on 8-Apr-2026 diff --git a/docs/runpodctl_ssh_remove-key.md b/docs/runpodctl_ssh_remove-key.md new file mode 100644 index 0000000..7c85934 --- /dev/null +++ b/docs/runpodctl_ssh_remove-key.md @@ -0,0 +1,31 @@ +## runpodctl ssh remove-key + +remove an ssh key + +### Synopsis + +remove an ssh key from your account + +``` +runpodctl ssh remove-key [flags] +``` + +### Options + +``` + --fingerprint string fingerprint of the key to remove + -h, --help help for remove-key + --name string name of the key to remove +``` + +### Options inherited from parent commands + +``` + -o, --output string output format (json, yaml) (default "json") +``` + +### SEE ALSO + +* [runpodctl ssh](runpodctl_ssh.md) - manage ssh keys and connections + +###### Auto generated by spf13/cobra on 8-Apr-2026 diff --git a/internal/api/graphql.go b/internal/api/graphql.go index 93b254b..af79d62 100644 --- a/internal/api/graphql.go +++ b/internal/api/graphql.go @@ -212,6 +212,88 @@ func (c *GraphQLClient) AddPublicSSHKey(key []byte) error { return nil } +// RemovePublicSSHKey removes a single SSH key via GraphQL by rewriting pubKey. +func (c *GraphQLClient) RemovePublicSSHKey(name, fingerprint string) error { + rawKeys, existingKeys, err := c.GetPublicSSHKeys() + if err != nil { + return fmt.Errorf("failed to get existing SSH keys: %w", err) + } + + matchCount := 0 + for _, key := range existingKeys { + if sshKeyMatches(key, name, fingerprint) { + matchCount++ + } + } + + switch { + case matchCount == 0: + return fmt.Errorf("ssh key not found") + case name != "" && fingerprint == "" && matchCount > 1: + return fmt.Errorf("multiple ssh keys found with name %q; use --fingerprint", name) + } + + var kept []string + for _, keyString := range splitSSHKeyBlock(rawKeys) { + pubKey, nameValue, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) + if err != nil { + kept = append(kept, keyString) + continue + } + + key := SSHKey{ + Name: nameValue, + Type: pubKey.Type(), + Key: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))), + Fingerprint: ssh.FingerprintSHA256(pubKey), + } + if sshKeyMatches(key, name, fingerprint) { + continue + } + + kept = append(kept, strings.TrimSpace(keyString)) + } + + input := GraphQLInput{ + Query: ` + mutation Mutation($input: UpdateUserSettingsInput) { + updateUserSettings(input: $input) { + id + } + } + `, + Variables: map[string]interface{}{"input": map[string]interface{}{"pubKey": strings.Join(kept, "\n\n")}}, + } + + if _, err = c.Query(input); err != nil { + return fmt.Errorf("failed to update SSH keys: %w", err) + } + + return nil +} + +func splitSSHKeyBlock(rawKeys string) []string { + var keys []string + for _, keyString := range strings.Split(rawKeys, "\n") { + trimmed := strings.TrimSpace(keyString) + if trimmed == "" { + continue + } + keys = append(keys, trimmed) + } + return keys +} + +func sshKeyMatches(key SSHKey, name, fingerprint string) bool { + if fingerprint != "" && key.Fingerprint != fingerprint { + return false + } + if name != "" && key.Name != name { + return false + } + return name != "" || fingerprint != "" +} + // PodEnvVar is a key-value pair for pod environment variables (GraphQL format) type PodEnvVar struct { Key string `json:"key"` diff --git a/internal/api/graphql_test.go b/internal/api/graphql_test.go new file mode 100644 index 0000000..82b903b --- /dev/null +++ b/internal/api/graphql_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSSHKeyMatches(t *testing.T) { + key := SSHKey{ + Name: "temp-key", + Fingerprint: "SHA256:test", + } + + if !sshKeyMatches(key, "temp-key", "") { + t.Fatal("expected name match") + } + if !sshKeyMatches(key, "", "SHA256:test") { + t.Fatal("expected fingerprint match") + } + if !sshKeyMatches(key, "temp-key", "SHA256:test") { + t.Fatal("expected combined match") + } + if sshKeyMatches(key, "", "") { + t.Fatal("expected empty selector not to match") + } + if sshKeyMatches(key, "other", "") { + t.Fatal("expected wrong name not to match") + } + if sshKeyMatches(key, "", "SHA256:other") { + t.Fatal("expected wrong fingerprint not to match") + } +} + +func TestSplitSSHKeyBlock(t *testing.T) { + keys := splitSSHKeyBlock("\nssh-ed25519 aaa first\n\nssh-rsa bbb second\n") + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d", len(keys)) + } + if keys[0] != "ssh-ed25519 aaa first" { + t.Fatalf("unexpected first key: %q", keys[0]) + } + if keys[1] != "ssh-rsa bbb second" { + t.Fatalf("unexpected second key: %q", keys[1]) + } +} + +func TestRemovePublicSSHKey_ByName(t *testing.T) { + const ( + keyOne = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBt1lsGGT0o42If0D6v0gk6r4oeKXH7D7x7qSWv8eQzG first-key" + keyTwo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP4F5wuS0nPf3B1L6xQ3K6Y1sY1R9e6lV2YxWw8P4v8K keep-key" + ) + + var updatedPubKey string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var input GraphQLInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Fatalf("decode request: %v", err) + } + + switch { + case strings.Contains(input.Query, "query myself"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "myself": map[string]interface{}{ + "pubKey": keyOne + "\n\n" + keyTwo, + }, + }, + }) + case strings.Contains(input.Query, "mutation Mutation"): + pubKey, _ := input.Variables["input"].(map[string]interface{})["pubKey"].(string) + updatedPubKey = pubKey + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "updateUserSettings": map[string]interface{}{"id": "user-1"}, + }, + }) + default: + t.Fatalf("unexpected query: %s", input.Query) + } + })) + defer server.Close() + + client := &GraphQLClient{ + url: server.URL, + apiKey: "test-key", + httpClient: server.Client(), + userAgent: "test", + } + + if err := client.RemovePublicSSHKey("first-key", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(updatedPubKey, "first-key") { + t.Fatalf("expected first key to be removed, got %q", updatedPubKey) + } + if !strings.Contains(updatedPubKey, "keep-key") { + t.Fatalf("expected second key to remain, got %q", updatedPubKey) + } +} + +func TestRemovePublicSSHKey_AmbiguousName(t *testing.T) { + const duplicateKeys = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBt1lsGGT0o42If0D6v0gk6r4oeKXH7D7x7qSWv8eQzG temp-key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP4F5wuS0nPf3B1L6xQ3K6Y1sY1R9e6lV2YxWw8P4v8K temp-key" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "myself": map[string]interface{}{ + "pubKey": duplicateKeys, + }, + }, + }) + })) + defer server.Close() + + client := &GraphQLClient{ + url: server.URL, + apiKey: "test-key", + httpClient: server.Client(), + userAgent: "test", + } + + err := client.RemovePublicSSHKey("temp-key", "") + if err == nil { + t.Fatal("expected ambiguous name error") + } + if !strings.Contains(err.Error(), "multiple ssh keys found") { + t.Fatalf("unexpected error: %v", err) + } +}