/
configssh.go
314 lines (269 loc) · 10.2 KB
/
configssh.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
package cmd
import (
"context"
"fmt"
"io/ioutil"
"os"
"os/user"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/cli/safeexec"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"cdr.dev/coder-cli/coder-sdk"
"cdr.dev/coder-cli/internal/coderutil"
"cdr.dev/coder-cli/pkg/clog"
)
const sshStartToken = "# ------------START-CODER-ENTERPRISE-----------"
const sshStartMessage = `# The following has been auto-generated by "coder config-ssh"
# to make accessing your Coder workspaces easier.
#
# To remove this blob, run:
#
# coder config-ssh --remove
#
# You should not hand-edit this section, unless you are deleting it.`
const sshEndToken = "# ------------END-CODER-ENTERPRISE------------"
func configSSHCmd() *cobra.Command {
var (
configpath string
remove = false
additionalOptions []string
)
cmd := &cobra.Command{
Use: "config-ssh",
Short: "Configure SSH to access Coder workspaces",
Long: "Inject the proper OpenSSH configuration into your local SSH config file.",
RunE: configSSH(&configpath, &remove, &additionalOptions),
}
cmd.Flags().StringVar(&configpath, "filepath", filepath.Join("~", ".ssh", "config"), "override the default path of your ssh config file")
cmd.Flags().StringSliceVarP(&additionalOptions, "option", "o", []string{}, "additional options injected in the ssh config (ex. disable caching with \"-o ControlPath=none\")")
cmd.Flags().BoolVar(&remove, "remove", false, "remove the auto-generated Coder ssh config")
return cmd
}
func configSSH(configpath *string, remove *bool, additionalOptions *[]string) func(cmd *cobra.Command, _ []string) error {
return func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
usr, err := user.Current()
if err != nil {
return xerrors.Errorf("get user home directory: %w", err)
}
privateKeyFilepath := filepath.Join(usr.HomeDir, ".ssh", "coder_enterprise")
if strings.HasPrefix(*configpath, "~") {
*configpath = strings.Replace(*configpath, "~", usr.HomeDir, 1)
}
currentConfig, err := readStr(*configpath)
if os.IsNotExist(err) {
// SSH configs are not always already there.
currentConfig = ""
} else if err != nil {
return xerrors.Errorf("read ssh config file %q: %w", *configpath, err)
}
currentConfig, didRemoveConfig := removeOldConfig(currentConfig)
if *remove {
if !didRemoveConfig {
return xerrors.Errorf("the Coder ssh configuration section could not be safely deleted or does not exist")
}
err = writeStr(*configpath, currentConfig)
if err != nil {
return xerrors.Errorf("write to ssh config file %q: %s", *configpath, err)
}
_ = os.Remove(privateKeyFilepath)
return nil
}
client, err := newClient(ctx, true)
if err != nil {
return err
}
user, err := client.Me(ctx)
if err != nil {
return xerrors.Errorf("fetch username: %w", err)
}
workspaces, err := getWorkspaces(ctx, client, coder.Me)
if err != nil {
return err
}
if len(workspaces) < 1 {
return xerrors.New("no workspaces found")
}
workspacesWithProviders, err := coderutil.WorkspacesWithProvider(ctx, client, workspaces)
if err != nil {
return xerrors.Errorf("resolve workspace workspace providers: %w", err)
}
if !sshAvailable(workspacesWithProviders) {
return xerrors.New("SSH is disabled or not available for any workspaces in your Coder deployment.")
}
binPath, err := binPath()
if err != nil {
return xerrors.Errorf("Failed to get executable path: %w", err)
}
newConfig := makeNewConfigs(binPath, workspacesWithProviders, privateKeyFilepath, *additionalOptions)
err = os.MkdirAll(filepath.Dir(*configpath), os.ModePerm)
if err != nil {
return xerrors.Errorf("make configuration directory: %w", err)
}
err = writeStr(*configpath, currentConfig+newConfig)
if err != nil {
return xerrors.Errorf("write new configurations to ssh config file %q: %w", *configpath, err)
}
err = writeSSHKey(ctx, client, privateKeyFilepath)
if err != nil {
if !xerrors.Is(err, os.ErrPermission) {
return xerrors.Errorf("write ssh key: %w", err)
}
fmt.Printf("Your private ssh key already exists at \"%s\"\nYou may need to remove the existing private key file and re-run this command\n\n", privateKeyFilepath)
} else {
fmt.Printf("Your private ssh key was written to \"%s\"\n", privateKeyFilepath)
}
writeSSHUXState(ctx, client, user.ID, workspaces)
fmt.Printf("An auto-generated ssh config was written to \"%s\"\n", *configpath)
fmt.Println("You should now be able to ssh into your workspace")
fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", workspaces[0].Name)
return nil
}
}
// binPath returns the path to the coder binary suitable for use in ssh
// ProxyCommand.
func binPath() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", xerrors.Errorf("get executable path: %w", err)
}
// On Windows, the coder-cli executable must be in $PATH for both Msys2/Git
// Bash and OpenSSH for Windows (used by Powershell and VS Code) to function
// correctly. Check if the current executable is in $PATH, and warn the user
// if it isn't.
if runtime.GOOS == goosWindows {
binName := filepath.Base(exePath)
// We use safeexec instead of os/exec because os/exec returns paths in
// the current working directory, which we will run into very often when
// looking for our own path.
pathPath, err := safeexec.LookPath(binName)
if err != nil {
clog.LogWarn(
"The current executable is not in $PATH.",
"This may lead to problems connecting to your workspace via SSH.",
fmt.Sprintf("Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again.", binName, binName),
)
// Return the exePath so SSH at least works outside of Msys2.
return exePath, nil
}
// Warn the user if the current executable is not the same as the one in
// $PATH.
if filepath.Clean(pathPath) != filepath.Clean(exePath) {
clog.LogWarn(
"The current executable path does not match the executable path found in $PATH.",
"This may lead to problems connecting to your workspace via SSH.",
fmt.Sprintf("\t Current executable path: %q", exePath),
fmt.Sprintf("\tExecutable path in $PATH: %q", pathPath),
)
}
return binName, nil
}
// On platforms other than Windows we can use the full path to the binary.
return exePath, nil
}
// removeOldConfig removes the old ssh configuration from the user's sshconfig.
// Returns true if the config was modified.
func removeOldConfig(config string) (string, bool) {
startIndex := strings.Index(config, sshStartToken)
endIndex := strings.Index(config, sshEndToken)
if startIndex == -1 || endIndex == -1 {
return config, false
}
if startIndex == 0 {
return config[endIndex+len(sshEndToken)+1:], true
}
return config[:startIndex-1] + config[endIndex+len(sshEndToken)+1:], true
}
// sshAvailable returns true if SSH is available for at least one workspace.
func sshAvailable(workspaces []coderutil.WorkspaceWithWorkspaceProvider) bool {
for _, workspace := range workspaces {
if workspace.WorkspaceProvider.SSHEnabled {
return true
}
}
return false
}
func writeSSHKey(ctx context.Context, client coder.Client, privateKeyPath string) error {
key, err := client.SSHKey(ctx)
if err != nil {
return err
}
return ioutil.WriteFile(privateKeyPath, []byte(key.PrivateKey), 0600)
}
func makeNewConfigs(binPath string, workspaces []coderutil.WorkspaceWithWorkspaceProvider, privateKeyFilepath string, additionalOptions []string) string {
newConfig := fmt.Sprintf("\n%s\n%s\n\n", sshStartToken, sshStartMessage)
sort.Slice(workspaces, func(i, j int) bool { return workspaces[i].Workspace.Name < workspaces[j].Workspace.Name })
for _, workspace := range workspaces {
if !workspace.WorkspaceProvider.SSHEnabled {
clog.LogWarn(fmt.Sprintf("SSH is not enabled for workspace provider %q", workspace.WorkspaceProvider.Name),
clog.BlankLine,
clog.Tipf("ask an infrastructure administrator to enable SSH for this workspace provider"),
)
continue
}
newConfig += makeSSHConfig(binPath, workspace.Workspace.Name, privateKeyFilepath, additionalOptions)
}
newConfig += fmt.Sprintf("\n%s\n", sshEndToken)
return newConfig
}
func makeSSHConfig(binPath, workspaceName, privateKeyFilepath string, additionalOptions []string) string {
// Custom user options come first to maximizessh customization.
options := []string{}
if len(additionalOptions) > 0 {
options = []string{
"# Custom options. Duplicated values will always prefer the first!",
}
options = append(options, additionalOptions...)
options = append(options, "# End custom options.")
}
options = append(options,
fmt.Sprintf("HostName coder.%s", workspaceName),
fmt.Sprintf("ProxyCommand %s", proxyCommand(binPath, workspaceName, true)),
"StrictHostKeyChecking no",
"ConnectTimeout=0",
"IdentitiesOnly yes",
fmt.Sprintf("IdentityFile=%q", privateKeyFilepath),
)
if runtime.GOOS == goosLinux || runtime.GOOS == goosDarwin {
options = append(options,
"ControlMaster auto",
"ControlPath ~/.ssh/.connection-%r@%h:%p",
"ControlPersist 600",
)
}
return fmt.Sprintf("Host coder.%s\n\t%s\n\n", workspaceName, strings.Join(options, "\n\t"))
}
func proxyCommand(binPath, workspaceName string, quoted bool) string {
if quoted {
binPath = fmt.Sprintf("%q", binPath)
}
return fmt.Sprintf(`%s tunnel %s 12213 stdio`, binPath, workspaceName)
}
func writeStr(filename, data string) error {
return ioutil.WriteFile(filename, []byte(data), 0777)
}
func readStr(filename string) (string, error) {
contents, err := ioutil.ReadFile(filename)
if err != nil {
return "", err
}
return string(contents), nil
}
func writeSSHUXState(ctx context.Context, client coder.Client, userID string, workspaces []coder.Workspace) {
// Create a map of workspace.ID -> true to indicate to the web client that all
// current workspaces have SSH configured
cliSSHConfigured := make(map[string]bool)
for _, workspace := range workspaces {
cliSSHConfigured[workspace.ID] = true
}
// Update UXState that coder config-ssh has been run by the currently
// authenticated user
err := client.UpdateUXState(ctx, userID, map[string]interface{}{"cliSSHConfigured": cliSSHConfigured})
if err != nil {
clog.LogWarn("The Coder web client may not recognize that you've configured SSH.")
}
}