Skip to content

Commit 9e2eb4b

Browse files
committed
feat: Add config-ssh command
Closes #254 and #499.
1 parent 1bab7e8 commit 9e2eb4b

File tree

14 files changed

+314
-48
lines changed

14 files changed

+314
-48
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"cSpell.words": [
3+
"cliflag",
34
"cliui",
45
"coderd",
56
"coderdtest",

agent/agent.go

+6-18
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func (s *agent) init(ctx context.Context) {
148148
Handler: func(session ssh.Session) {
149149
err := s.handleSSHSession(session)
150150
if err != nil {
151-
s.options.Logger.Debug(ctx, "ssh session failed", slog.Error(err))
151+
s.options.Logger.Warn(ctx, "ssh session failed", slog.Error(err))
152152
_ = session.Exit(1)
153153
return
154154
}
@@ -177,12 +177,6 @@ func (s *agent) init(ctx context.Context) {
177177
},
178178
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
179179
return &gossh.ServerConfig{
180-
Config: gossh.Config{
181-
// "arcfour" is the fastest SSH cipher. We prioritize throughput
182-
// over encryption here, because the WebRTC connection is already
183-
// encrypted. If possible, we'd disable encryption entirely here.
184-
Ciphers: []string{"arcfour"},
185-
},
186180
NoClientAuth: true,
187181
}
188182
},
@@ -198,14 +192,11 @@ func (*agent) handleSSHSession(session ssh.Session) error {
198192
err error
199193
)
200194

201-
username := session.User()
202-
if username == "" {
203-
currentUser, err := user.Current()
204-
if err != nil {
205-
return xerrors.Errorf("get current user: %w", err)
206-
}
207-
username = currentUser.Username
195+
currentUser, err := user.Current()
196+
if err != nil {
197+
return xerrors.Errorf("get current user: %w", err)
208198
}
199+
username := currentUser.Username
209200

210201
// gliderlabs/ssh returns a command slice of zero
211202
// when a shell is requested.
@@ -249,10 +240,7 @@ func (*agent) handleSSHSession(session ssh.Session) error {
249240
}
250241
go func() {
251242
for win := range windowSize {
252-
err := ptty.Resize(uint16(win.Width), uint16(win.Height))
253-
if err != nil {
254-
panic(err)
255-
}
243+
_ = ptty.Resize(uint16(win.Width), uint16(win.Height))
256244
}
257245
}()
258246
go func() {

agent/conn.go

-3
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,6 @@ func (c *Conn) SSHClient() (*ssh.Client, error) {
3939
return nil, xerrors.Errorf("ssh: %w", err)
4040
}
4141
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
42-
Config: ssh.Config{
43-
Ciphers: []string{"arcfour"},
44-
},
4542
// SSH host validation isn't helpful, because obtaining a peer
4643
// connection already signifies user-intent to dial a workspace.
4744
// #nosec

agent/usershell/usershell_other.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@ func Get(username string) (string, error) {
2727
}
2828
return parts[6], nil
2929
}
30-
return "", xerrors.New("user not found in /etc/passwd and $SHELL not set")
30+
return "", xerrors.Errorf("user %q not found in /etc/passwd", username)
3131
}

cli/cliui/agent.go

+11-10
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ package cliui
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"sync"
78
"time"
89

910
"github.com/briandowns/spinner"
10-
"github.com/spf13/cobra"
1111
"golang.org/x/xerrors"
1212

1313
"github.com/coder/coder/codersdk"
@@ -21,15 +21,15 @@ type AgentOptions struct {
2121
}
2222

2323
// Agent displays a spinning indicator that waits for a workspace agent to connect.
24-
func Agent(cmd *cobra.Command, opts AgentOptions) error {
24+
func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
2525
if opts.FetchInterval == 0 {
2626
opts.FetchInterval = 500 * time.Millisecond
2727
}
2828
if opts.WarnInterval == 0 {
2929
opts.WarnInterval = 30 * time.Second
3030
}
3131
var resourceMutex sync.Mutex
32-
resource, err := opts.Fetch(cmd.Context())
32+
resource, err := opts.Fetch(ctx)
3333
if err != nil {
3434
return xerrors.Errorf("fetch: %w", err)
3535
}
@@ -40,7 +40,8 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error {
4040
opts.WarnInterval = 0
4141
}
4242
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
43-
spin.Writer = cmd.OutOrStdout()
43+
spin.Writer = writer
44+
spin.ForceOutput = true
4445
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(resource.Type+"."+resource.Name) + "..."
4546
spin.Start()
4647
defer spin.Stop()
@@ -51,7 +52,7 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error {
5152
defer timer.Stop()
5253
go func() {
5354
select {
54-
case <-cmd.Context().Done():
55+
case <-ctx.Done():
5556
return
5657
case <-timer.C:
5758
}
@@ -63,17 +64,17 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error {
6364
}
6465
// This saves the cursor position, then defers clearing from the cursor
6566
// position to the end of the screen.
66-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message))
67-
defer fmt.Fprintf(cmd.OutOrStdout(), "\033[u\033[J")
67+
_, _ = fmt.Fprintf(writer, "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message))
68+
defer fmt.Fprintf(writer, "\033[u\033[J")
6869
}()
6970
for {
7071
select {
71-
case <-cmd.Context().Done():
72-
return cmd.Context().Err()
72+
case <-ctx.Done():
73+
return ctx.Err()
7374
case <-ticker.C:
7475
}
7576
resourceMutex.Lock()
76-
resource, err = opts.Fetch(cmd.Context())
77+
resource, err = opts.Fetch(ctx)
7778
if err != nil {
7879
return xerrors.Errorf("fetch: %w", err)
7980
}

cli/cliui/agent_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestAgent(t *testing.T) {
2020
ptty := ptytest.New(t)
2121
cmd := &cobra.Command{
2222
RunE: func(cmd *cobra.Command, args []string) error {
23-
err := cliui.Agent(cmd, cliui.AgentOptions{
23+
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
2424
WorkspaceName: "example",
2525
Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) {
2626
resource := codersdk.WorkspaceResource{

cli/cliui/log.go

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package cliui
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
"github.com/charmbracelet/lipgloss"
9+
)
10+
11+
// cliMessage provides a human-readable message for CLI errors and messages.
12+
type cliMessage struct {
13+
Level string
14+
Style lipgloss.Style
15+
Header string
16+
Lines []string
17+
}
18+
19+
// String formats the CLI message for consumption by a human.
20+
func (m cliMessage) String() string {
21+
var str strings.Builder
22+
_, _ = fmt.Fprintf(&str, "%s\r\n",
23+
Styles.Bold.Render(m.Header))
24+
for _, line := range m.Lines {
25+
_, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line)
26+
}
27+
return str.String()
28+
}
29+
30+
// Warn writes a log to the writer provided.
31+
func Warn(wtr io.Writer, header string, lines ...string) {
32+
_, _ = fmt.Fprint(wtr, cliMessage{
33+
Level: "warning",
34+
Style: Styles.Warn,
35+
Header: header,
36+
Lines: lines,
37+
}.String())
38+
}

cli/configssh.go

+142-9
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,159 @@
11
package cli
22

33
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"sync"
10+
11+
"github.com/cli/safeexec"
412
"github.com/spf13/cobra"
13+
"golang.org/x/sync/errgroup"
14+
"golang.org/x/xerrors"
15+
16+
"github.com/coder/coder/cli/cliflag"
17+
"github.com/coder/coder/cli/cliui"
18+
"github.com/coder/coder/codersdk"
519
)
620

7-
// const sshStartToken = "# ------------START-CODER-----------"
8-
// const sshStartMessage = `# This was generated by "coder config-ssh".
9-
// #
10-
// # To remove this blob, run:
11-
// #
12-
// # coder config-ssh --remove
13-
// #
14-
// # You should not hand-edit this section, unless you are deleting it.`
15-
// const sshEndToken = "# ------------END-CODER------------"
21+
const sshStartToken = "# ------------START-CODER-----------"
22+
const sshStartMessage = `# This was generated by "coder config-ssh".
23+
#
24+
# To remove this blob, run:
25+
#
26+
# coder config-ssh --remove
27+
#
28+
# You should not hand-edit this section, unless you are deleting it.`
29+
const sshEndToken = "# ------------END-CODER------------"
1630

1731
func configSSH() *cobra.Command {
32+
var (
33+
sshConfigFile string
34+
)
1835
cmd := &cobra.Command{
1936
Use: "config-ssh",
2037
RunE: func(cmd *cobra.Command, args []string) error {
38+
client, err := createClient(cmd)
39+
if err != nil {
40+
return err
41+
}
42+
if strings.HasPrefix(sshConfigFile, "~/") {
43+
dirname, _ := os.UserHomeDir()
44+
sshConfigFile = filepath.Join(dirname, sshConfigFile[2:])
45+
}
46+
// Doesn't matter if this fails, because we write the file anyways.
47+
sshConfigContentRaw, _ := os.ReadFile(sshConfigFile)
48+
sshConfigContent := string(sshConfigContentRaw)
49+
startIndex := strings.Index(sshConfigContent, sshStartToken)
50+
endIndex := strings.Index(sshConfigContent, sshEndToken)
51+
if startIndex != -1 && endIndex != -1 {
52+
sshConfigContent = sshConfigContent[:startIndex-1] + sshConfigContent[endIndex+len(sshEndToken):]
53+
}
54+
55+
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
56+
if err != nil {
57+
return err
58+
}
59+
binPath, err := currentBinPath(cmd)
60+
if err != nil {
61+
return err
62+
}
63+
64+
sshConfigContent += "\n" + sshStartToken + "\n" + sshStartMessage + "\n\n"
65+
sshConfigContentMutex := sync.Mutex{}
66+
var errGroup errgroup.Group
67+
for _, workspace := range workspaces {
68+
workspace := workspace
69+
errGroup.Go(func() error {
70+
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
71+
if err != nil {
72+
return err
73+
}
74+
resourcesWithAgents := make([]codersdk.WorkspaceResource, 0)
75+
for _, resource := range resources {
76+
if resource.Agent == nil {
77+
continue
78+
}
79+
resourcesWithAgents = append(resourcesWithAgents, resource)
80+
}
81+
sshConfigContentMutex.Lock()
82+
defer sshConfigContentMutex.Unlock()
83+
if len(resourcesWithAgents) == 1 {
84+
sshConfigContent += strings.Join([]string{
85+
"Host coder." + workspace.Name,
86+
"\tHostName coder." + workspace.Name,
87+
fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, workspace.Name),
88+
"\tConnectTimeout=0",
89+
"\tStrictHostKeyChecking=no",
90+
}, "\n") + "\n"
91+
}
92+
93+
return nil
94+
})
95+
}
96+
err = errGroup.Wait()
97+
if err != nil {
98+
return err
99+
}
100+
sshConfigContent += "\n" + sshEndToken
101+
err = os.MkdirAll(filepath.Dir(sshConfigFile), os.ModePerm)
102+
if err != nil {
103+
return err
104+
}
105+
err = os.WriteFile(sshConfigFile, []byte(sshConfigContent), os.ModePerm)
106+
if err != nil {
107+
return err
108+
}
109+
_, _ = fmt.Printf("An auto-generated ssh config was written to \"%s\"\n", sshConfigFile)
110+
_, _ = fmt.Println("You should now be able to ssh into your workspace")
111+
_, _ = fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", workspaces[0].Name)
21112
return nil
22113
},
23114
}
115+
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.")
24116

25117
return cmd
26118
}
119+
120+
// currentBinPath returns the path to the coder binary suitable for use in ssh
121+
// ProxyCommand.
122+
func currentBinPath(cmd *cobra.Command) (string, error) {
123+
exePath, err := os.Executable()
124+
if err != nil {
125+
return "", xerrors.Errorf("get executable path: %w", err)
126+
}
127+
128+
binName := filepath.Base(exePath)
129+
// We use safeexec instead of os/exec because os/exec returns paths in
130+
// the current working directory, which we will run into very often when
131+
// looking for our own path.
132+
pathPath, err := safeexec.LookPath(binName)
133+
// On Windows, the coder-cli executable must be in $PATH for both Msys2/Git
134+
// Bash and OpenSSH for Windows (used by Powershell and VS Code) to function
135+
// correctly. Check if the current executable is in $PATH, and warn the user
136+
// if it isn't.
137+
if err != nil && runtime.GOOS == "windows" {
138+
cliui.Warn(cmd.OutOrStdout(),
139+
"The current executable is not in $PATH.",
140+
"This may lead to problems connecting to your workspace via SSH.",
141+
fmt.Sprintf("Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again.", binName, binName),
142+
)
143+
// Return the exePath so SSH at least works outside of Msys2.
144+
return exePath, nil
145+
}
146+
147+
// Warn the user if the current executable is not the same as the one in
148+
// $PATH.
149+
if filepath.Clean(pathPath) != filepath.Clean(exePath) {
150+
cliui.Warn(cmd.OutOrStdout(),
151+
"The current executable path does not match the executable path found in $PATH.",
152+
"This may cause issues connecting to your workspace via SSH.",
153+
fmt.Sprintf("\tCurrent executable path: %q", exePath),
154+
fmt.Sprintf("\tExecutable path in $PATH: %q", pathPath),
155+
)
156+
}
157+
158+
return binName, nil
159+
}

cli/configssh_test.go

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package cli_test
2+
3+
import "testing"
4+
5+
func TestConfigSSH(t *testing.T) {
6+
t.Parallel()
7+
}

0 commit comments

Comments
 (0)