Skip to content

Commit 29377ac

Browse files
feat: added temporary ssh key gen support
1 parent 2734903 commit 29377ac

5 files changed

Lines changed: 175 additions & 26 deletions

File tree

cmd/completion/completion.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Package completion provides shell completion script generation.
2+
package completion
3+
4+
import (
5+
"fmt"
6+
7+
"github.com/urfave/cli/v2"
8+
)
9+
10+
const bashScript = `# bash completion for createos
11+
_createos_completion() {
12+
local cur="${COMP_WORDS[COMP_CWORD]}"
13+
local completions
14+
completions=$(createos --generate-bash-completion "${COMP_WORDS[@]:1}" 2>/dev/null)
15+
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
16+
return 0
17+
}
18+
complete -F _createos_completion createos`
19+
20+
const zshScript = `# zsh completion for createos
21+
#compdef createos
22+
23+
_createos() {
24+
local -a completions
25+
completions=("${(@f)$(${words[1]} --generate-bash-completion ${words[2,-1]} 2>/dev/null)}")
26+
compadd -a completions
27+
}
28+
29+
_createos "$@"`
30+
31+
const fishScript = `# fish completion for createos
32+
function __createos_complete
33+
set -l args (commandline -opc)
34+
set -e args[1]
35+
createos --generate-bash-completion $args
36+
end
37+
38+
complete -f -c createos -a "(__createos_complete)"`
39+
40+
// NewCompletionCommand returns the shell completion command.
41+
func NewCompletionCommand() *cli.Command {
42+
return &cli.Command{
43+
Name: "completion",
44+
Usage: "Generate shell completion script",
45+
ArgsUsage: "<bash|zsh|fish>",
46+
Description: "Generate a shell completion script for createos.\n\n" +
47+
" Add the output to your shell profile to enable tab completion.\n\n" +
48+
" Bash:\n" +
49+
" source <(createos completion bash)\n\n" +
50+
" Zsh:\n" +
51+
" source <(createos completion zsh)\n\n" +
52+
" Fish:\n" +
53+
" createos completion fish | source",
54+
Action: func(c *cli.Context) error {
55+
shell := c.Args().First()
56+
switch shell {
57+
case "bash":
58+
fmt.Println(bashScript)
59+
case "zsh":
60+
fmt.Println(zshScript)
61+
case "fish":
62+
fmt.Println(fishScript)
63+
case "":
64+
return fmt.Errorf("please specify a shell\n\n Supported shells: bash, zsh, fish\n\n Example:\n createos completion zsh")
65+
default:
66+
return fmt.Errorf("unsupported shell %q\n\n Supported shells: bash, zsh, fish", shell)
67+
}
68+
return nil
69+
},
70+
}
71+
}

cmd/root/root.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/urfave/cli/v2"
99

1010
"github.com/NodeOps-app/createos-cli/cmd/auth"
11+
"github.com/NodeOps-app/createos-cli/cmd/completion"
1112
"github.com/NodeOps-app/createos-cli/cmd/deployments"
1213
"github.com/NodeOps-app/createos-cli/cmd/domains"
1314
"github.com/NodeOps-app/createos-cli/cmd/environments"
@@ -27,9 +28,10 @@ import (
2728
// NewApp creates and configures the root CLI application.
2829
func NewApp() *cli.App {
2930
app := &cli.App{
30-
Name: "createos",
31-
Usage: "CreateOS CLI - Manage your infrastructure",
32-
Version: version.Version,
31+
Name: "createos",
32+
Usage: "CreateOS CLI - Manage your infrastructure",
33+
Version: version.Version,
34+
EnableBashCompletion: true,
3335
Flags: []cli.Flag{
3436
&cli.BoolFlag{
3537
Name: "debug",
@@ -46,7 +48,7 @@ func NewApp() *cli.App {
4648
},
4749
Before: func(c *cli.Context) error {
4850
cmd := c.Args().First()
49-
if cmd == "" || cmd == "login" || cmd == "logout" || cmd == "version" {
51+
if cmd == "" || cmd == "login" || cmd == "logout" || cmd == "version" || cmd == "completion" {
5052
return nil
5153
}
5254

@@ -115,6 +117,7 @@ func NewApp() *cli.App {
115117
} else {
116118
fmt.Println(" login Authenticate with CreateOS")
117119
}
120+
fmt.Println(" completion Generate shell completion script")
118121
fmt.Println(" version Print the current version")
119122
fmt.Println()
120123
fmt.Println("Run 'createos <command> --help' for more information on a command.")
@@ -124,6 +127,7 @@ func NewApp() *cli.App {
124127
Commands: []*cli.Command{
125128
auth.NewLoginCommand(),
126129
auth.NewLogoutCommand(),
130+
completion.NewCompletionCommand(),
127131
deployments.NewDeploymentsCommand(),
128132
domains.NewDomainsCommand(),
129133
environments.NewEnvironmentsCommand(),

cmd/vms/vms_ssh.go

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package vms
22

33
import (
44
"context"
5+
"crypto/ed25519"
6+
"crypto/rand"
7+
"encoding/pem"
58
"fmt"
69
"os"
710
"os/exec"
@@ -10,6 +13,7 @@ import (
1013

1114
"github.com/pterm/pterm"
1215
"github.com/urfave/cli/v2"
16+
gossh "golang.org/x/crypto/ssh"
1317

1418
"github.com/NodeOps-app/createos-cli/internal/api"
1519
"github.com/NodeOps-app/createos-cli/internal/terminal"
@@ -43,20 +47,65 @@ func findPublicKeys() map[string]string {
4347
return keys
4448
}
4549

46-
// selectPublicKey prompts the user to pick a local SSH public key, or skip.
47-
// Returns the selected key content, or "" if skipped.
48-
func selectPublicKey() (string, error) {
49-
keys := findPublicKeys()
50-
if len(keys) == 0 {
51-
return "", nil
50+
// generateTempSSHKeypair creates a temporary ed25519 keypair, writes the private key
51+
// to a temp file, and returns the public key string, the private key path, and a
52+
// cleanup func that removes the temp file.
53+
func generateTempSSHKeypair() (publicKey string, privateKeyPath string, cleanup func(), err error) {
54+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
55+
if err != nil {
56+
return "", "", nil, fmt.Errorf("could not generate keypair: %w", err)
57+
}
58+
59+
// Marshal public key to authorized_keys format.
60+
sshPub, err := gossh.NewPublicKey(pub)
61+
if err != nil {
62+
return "", "", nil, fmt.Errorf("could not encode public key: %w", err)
63+
}
64+
pubKeyStr := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub)))
65+
66+
// Marshal private key to OpenSSH PEM format.
67+
privPEM, err := gossh.MarshalPrivateKey(priv, "")
68+
if err != nil {
69+
return "", "", nil, fmt.Errorf("could not encode private key: %w", err)
70+
}
71+
privBytes := pem.EncodeToMemory(privPEM)
72+
73+
// Write private key to a temp file with restricted permissions.
74+
f, err := os.CreateTemp("", "createos-vm-*.pem")
75+
if err != nil {
76+
return "", "", nil, fmt.Errorf("could not create temp key file: %w", err)
77+
}
78+
if err := os.Chmod(f.Name(), 0600); err != nil {
79+
_ = f.Close()
80+
_ = os.Remove(f.Name())
81+
return "", "", nil, fmt.Errorf("could not set key file permissions: %w", err)
82+
}
83+
if _, err := f.Write(privBytes); err != nil {
84+
_ = f.Close()
85+
_ = os.Remove(f.Name())
86+
return "", "", nil, fmt.Errorf("could not write private key: %w", err)
5287
}
88+
_ = f.Close()
5389

54-
const skipOption = "None (skip)"
55-
options := make([]string, 0, len(keys)+1)
90+
cleanup = func() { _ = os.Remove(f.Name()) }
91+
return pubKeyStr, f.Name(), cleanup, nil
92+
}
93+
94+
const (
95+
optionTempKey = "Generate a temporary key (auto-deleted on exit)"
96+
optionSkip = "None (skip)"
97+
)
98+
99+
// selectPublicKey prompts the user to pick a local SSH public key, generate a temp
100+
// key, or skip. Returns the public key content and an optional private key path
101+
// (non-empty only for generated temp keys).
102+
func selectPublicKey() (publicKey string, privateKeyPath string, err error) {
103+
keys := findPublicKeys()
104+
105+
options := make([]string, 0, len(keys)+2)
56106
nameByOption := make(map[string]string, len(keys))
57107

58108
for name, content := range keys {
59-
// Show filename + key comment (last field) for readability.
60109
parts := strings.Fields(content)
61110
label := name
62111
if len(parts) >= 3 {
@@ -65,19 +114,28 @@ func selectPublicKey() (string, error) {
65114
options = append(options, label)
66115
nameByOption[label] = content
67116
}
68-
options = append(options, skipOption)
117+
options = append(options, optionTempKey, optionSkip)
69118

70119
selected, err := pterm.DefaultInteractiveSelect.
71120
WithOptions(options).
72121
WithDefaultText("Select an SSH public key to use for this session").
73122
Show()
74123
if err != nil {
75-
return "", fmt.Errorf("could not read selection: %w", err)
124+
return "", "", fmt.Errorf("could not read selection: %w", err)
76125
}
77-
if selected == skipOption {
78-
return "", nil
126+
127+
switch selected {
128+
case optionSkip:
129+
return "", "", nil
130+
case optionTempKey:
131+
pub, privPath, _, genErr := generateTempSSHKeypair()
132+
if genErr != nil {
133+
return "", "", genErr
134+
}
135+
return pub, privPath, nil
136+
default:
137+
return nameByOption[selected], "", nil
79138
}
80-
return nameByOption[selected], nil
81139
}
82140

83141
func newVMSSHCommand() *cli.Command {
@@ -122,11 +180,16 @@ func newVMSSHCommand() *cli.Command {
122180
return fmt.Errorf("VM does not have an IP address yet — try again in a moment\n\n Check status with: createos vms get %s", id)
123181
}
124182

125-
// Ask user to pick a local SSH public key for this session.
126-
localKey, err := selectPublicKey()
183+
localKey, privateKeyPath, err := selectPublicKey()
127184
if err != nil {
128185
return err
129186
}
187+
188+
// Clean up temp private key file on exit if one was generated.
189+
if privateKeyPath != "" {
190+
defer os.Remove(privateKeyPath) //nolint:errcheck
191+
}
192+
130193
originalKeys := vm.Inputs.SSHKeys
131194
if originalKeys == nil {
132195
originalKeys = []string{}
@@ -137,7 +200,6 @@ func newVMSSHCommand() *cli.Command {
137200
}
138201

139202
if localKey != "" {
140-
// Check if key is already present.
141203
alreadyPresent := false
142204
for _, k := range originalKeys {
143205
if strings.TrimSpace(k) == localKey {
@@ -149,10 +211,9 @@ func newVMSSHCommand() *cli.Command {
149211
if !alreadyPresent {
150212
updatedKeys := append(originalKeys, localKey) //nolint:gocritic
151213
if err := client.UpdateVMDeployment(id, updatedKeys, firewallRules); err != nil {
152-
pterm.Warning.Println("Could not add your SSH key to the VM — you may need to add it manually.")
214+
pterm.Warning.Printf("Could not add your SSH key to the VM: %v\n", err)
153215
} else {
154216
pterm.Info.Println("Your SSH public key has been added to the VM.")
155-
// Restore original keys on exit.
156217
defer func() {
157218
if err := client.UpdateVMDeployment(id, originalKeys, firewallRules); err != nil {
158219
pterm.Warning.Printf("Could not remove your SSH key from VM %q: %v\n", id, err)
@@ -165,7 +226,13 @@ func newVMSSHCommand() *cli.Command {
165226
}
166227

167228
target := user + "@" + vm.Extra.IPAddress
168-
cmd := exec.CommandContext(context.Background(), "ssh", "-o", "StrictHostKeyChecking=accept-new", target) //nolint:gosec // target is user@<api-provided-ip>, intentional subprocess
229+
sshArgs := []string{"-o", "StrictHostKeyChecking=accept-new"}
230+
if privateKeyPath != "" {
231+
sshArgs = append(sshArgs, "-i", privateKeyPath)
232+
}
233+
sshArgs = append(sshArgs, target)
234+
235+
cmd := exec.CommandContext(context.Background(), "ssh", sshArgs...) //nolint:gosec
169236
cmd.Stdin = os.Stdin
170237
cmd.Stdout = os.Stdout
171238
cmd.Stderr = os.Stderr

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ require (
3636
github.com/russross/blackfriday/v2 v2.1.0 // indirect
3737
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3838
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
39-
golang.org/x/net v0.43.0 // indirect
39+
golang.org/x/crypto v0.49.0 // indirect
40+
golang.org/x/net v0.51.0 // indirect
4041
golang.org/x/sys v0.42.0 // indirect
41-
golang.org/x/text v0.28.0 // indirect
42+
golang.org/x/text v0.35.0 // indirect
4243
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
105105
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
106106
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
107107
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
108+
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
109+
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
108110
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
109111
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
110112
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -115,6 +117,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
115117
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
116118
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
117119
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
120+
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
121+
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
118122
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
119123
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
120124
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -147,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
147151
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
148152
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
149153
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
154+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
155+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
150156
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
151157
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
152158
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

0 commit comments

Comments
 (0)