Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod-id>",
Short: "show ssh info for a pod",
Expand All @@ -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")
Expand Down Expand Up @@ -130,6 +148,22 @@ 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 {
output.Error(err)
return err
}

if err := client.RemovePublicSSHKey(sshKeyName, sshKeyFingerprint); err != nil {
output.Error(err)
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)
}
Expand Down
32 changes: 32 additions & 0 deletions cmd/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,40 @@ 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) {
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)
}
}
3 changes: 2 additions & 1 deletion docs/runpodctl_ssh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/runpodctl_ssh_add-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/runpodctl_ssh_info.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ runpodctl ssh info <pod-id> [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
2 changes: 1 addition & 1 deletion docs/runpodctl_ssh_list-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions docs/runpodctl_ssh_remove-key.md
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions internal/api/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
132 changes: 132 additions & 0 deletions internal/api/graphql_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}