From 29a48c034be371d8b4ad12375d7f35e48fd0bf40 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 9 Aug 2021 07:48:47 +0000 Subject: [PATCH 1/3] feat: Display error to user when SSH'ing into offline workspace --- go.mod | 3 +- go.sum | 6 +- internal/cmd/tunnel.go | 146 +++++++++++++++++++++++++++++++++-------- pkg/clog/clog.go | 4 +- 4 files changed, 127 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index b4e02184..09be0cc6 100644 --- a/go.mod +++ b/go.mod @@ -25,9 +25,10 @@ require ( github.com/rjeczalik/notify v0.9.2 github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0 + golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 golang.org/x/net v0.0.0-20210614182718-04defd469f4e golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 golang.org/x/time v0.0.0-20191024005414-555d28b269f0 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 diff --git a/go.sum b/go.sum index 30835c85..de738e5e 100644 --- a/go.sum +++ b/go.sum @@ -430,8 +430,9 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -590,8 +591,9 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/cmd/tunnel.go b/internal/cmd/tunnel.go index 116ecfa0..373f7ffd 100644 --- a/internal/cmd/tunnel.go +++ b/internal/cmd/tunnel.go @@ -12,12 +12,15 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "github.com/fatih/color" "github.com/pion/webrtc/v3" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" "golang.org/x/xerrors" "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/x/xcobra" + "cdr.dev/coder-cli/pkg/clog" "cdr.dev/coder-cli/wsnet" ) @@ -59,20 +62,34 @@ coder tunnel my-dev 3000 3000 } baseURL := sdk.BaseURL() - workspaces, err := getWorkspaces(ctx, sdk, coder.Me) + workspace, err := findWorkspace(ctx, sdk, args[0], coder.Me) if err != nil { return xerrors.Errorf("get workspaces: %w", err) } - var workspaceID string - for _, workspace := range workspaces { - if workspace.Name == args[0] { - workspaceID = workspace.ID - break + if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn { + color.NoColor = false + notAvailableError := clog.Error("workspace not available", + fmt.Sprintf("current status: \"%s\"", workspace.LatestStat.ContainerStatus), + clog.BlankLine, + clog.Tipf("use \"coder workspaces rebuild %s\" to rebuild this workspace", workspace.Name), + ) + // If we're attempting to forward our remote SSH port, + // we want to communicate with the OpenSSH protocol so + // SSH clients can properly display output to our users. + if remotePort == 12213 { + rawKey, err := sdk.SSHKey(ctx) + if err != nil { + return xerrors.Errorf("get ssh key: %w", err) + } + err = discardSSHConnection(&stdioConn{}, rawKey.PrivateKey, notAvailableError.String()) + if err != nil { + return err + } + return nil } - } - if workspaceID == "" { - return xerrors.Errorf("No workspace found by name '%s'", args[0]) + + return notAvailableError } iceServers, err := sdk.ICEServers(ctx) @@ -82,14 +99,14 @@ coder tunnel my-dev 3000 3000 log.Debug(ctx, "got ICE servers", slog.F("ice", iceServers)) c := &tunnneler{ - log: log, - brokerAddr: &baseURL, - token: sdk.Token(), - workspaceID: workspaceID, - iceServers: iceServers, - stdio: args[2] == "stdio", - localPort: uint16(localPort), - remotePort: uint16(remotePort), + log: log, + brokerAddr: &baseURL, + token: sdk.Token(), + workspace: workspace, + iceServers: iceServers, + stdio: args[2] == "stdio", + localPort: uint16(localPort), + remotePort: uint16(remotePort), } err = c.start(ctx) @@ -105,14 +122,14 @@ coder tunnel my-dev 3000 3000 } type tunnneler struct { - log slog.Logger - brokerAddr *url.URL - token string - workspaceID string - iceServers []webrtc.ICEServer - remotePort uint16 - localPort uint16 - stdio bool + log slog.Logger + brokerAddr *url.URL + token string + workspace *coder.Workspace + iceServers []webrtc.ICEServer + remotePort uint16 + localPort uint16 + stdio bool } func (c *tunnneler) start(ctx context.Context) error { @@ -121,7 +138,7 @@ func (c *tunnneler) start(ctx context.Context) error { dialLog := c.log.Named("wsnet") wd, err := wsnet.DialWebsocket( ctx, - wsnet.ConnectEndpoint(c.brokerAddr, c.workspaceID, c.token), + wsnet.ConnectEndpoint(c.brokerAddr, c.workspace.ID, c.token), &wsnet.DialOptions{ Log: &dialLog, TURNProxyAuthToken: c.token, @@ -156,7 +173,7 @@ func (c *tunnneler) start(ctx context.Context) error { return case <-ticker.C: // silently ignore failures so we don't spam the console - _ = sdk.UpdateLastConnectionAt(ctx, c.workspaceID) + _ = sdk.UpdateLastConnectionAt(ctx, c.workspace.ID) } } }() @@ -203,3 +220,78 @@ func (c *tunnneler) start(ctx context.Context) error { }() } } + +// Used to treat stdio like a connection for proxying SSH. +type stdioConn struct{} + +func (s *stdioConn) Read(b []byte) (n int, err error) { + return os.Stdin.Read(b) +} + +func (s *stdioConn) Write(b []byte) (n int, err error) { + return os.Stdout.Write(b) +} + +func (s *stdioConn) Close() error { + return nil +} + +func (s *stdioConn) LocalAddr() net.Addr { + return nil +} + +func (s *stdioConn) RemoteAddr() net.Addr { + return nil +} + +func (s *stdioConn) SetDeadline(t time.Time) error { + return nil +} + +func (s *stdioConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (s *stdioConn) SetWriteDeadline(t time.Time) error { + return nil +} + +// discardSSHConnection accepts a connection then outputs the message provided +// to any channel opened, immediately closing the connection afterwards. +// +// Used to provide status to connecting clients while still aligning with the +// native SSH protocol. +func discardSSHConnection(nc net.Conn, privateKey string, msg string) error { + config := &ssh.ServerConfig{ + NoClientAuth: true, + } + key, err := ssh.ParseRawPrivateKey([]byte(privateKey)) + if err != nil { + return fmt.Errorf("parse private key: %w", err) + } + signer, err := ssh.NewSignerFromKey(key) + if err != nil { + return fmt.Errorf("signer from private key: %w", err) + } + config.AddHostKey(signer) + conn, chans, reqs, err := ssh.NewServerConn(nc, config) + if err != nil { + return fmt.Errorf("create server conn: %w", err) + } + go ssh.DiscardRequests(reqs) + ch, req, err := (<-chans).Accept() + if err != nil { + return fmt.Errorf("accept channel: %w", err) + } + go ssh.DiscardRequests(req) + + _, err = ch.Write([]byte(msg)) + if err != nil { + return fmt.Errorf("write channel: %w", err) + } + err = ch.Close() + if err != nil { + return fmt.Errorf("close channel: %w", err) + } + return conn.Close() +} diff --git a/pkg/clog/clog.go b/pkg/clog/clog.go index 0a523e1f..0c88a29b 100644 --- a/pkg/clog/clog.go +++ b/pkg/clog/clog.go @@ -35,12 +35,12 @@ type CLIError struct { // String formats the CLI message for consumption by a human. func (m CLIMessage) String() string { var str strings.Builder - str.WriteString(fmt.Sprintf("%s: %s\n", + str.WriteString(fmt.Sprintf("%s: %s\r\n", color.New(m.Color).Sprint(m.Level), color.New(color.Bold).Sprint(m.Header)), ) for _, line := range m.Lines { - str.WriteString(fmt.Sprintf(" %s %s\n", color.New(m.Color).Sprint("|"), line)) + str.WriteString(fmt.Sprintf(" %s %s\r\n", color.New(m.Color).Sprint("|"), line)) } return str.String() } From 46c11bf4aa5efce16717580aa077ed1015e4b7d4 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 9 Aug 2021 07:54:18 +0000 Subject: [PATCH 2/3] Remove new \r --- pkg/clog/clog.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/clog/clog.go b/pkg/clog/clog.go index 0c88a29b..0a523e1f 100644 --- a/pkg/clog/clog.go +++ b/pkg/clog/clog.go @@ -35,12 +35,12 @@ type CLIError struct { // String formats the CLI message for consumption by a human. func (m CLIMessage) String() string { var str strings.Builder - str.WriteString(fmt.Sprintf("%s: %s\r\n", + str.WriteString(fmt.Sprintf("%s: %s\n", color.New(m.Color).Sprint(m.Level), color.New(color.Bold).Sprint(m.Header)), ) for _, line := range m.Lines { - str.WriteString(fmt.Sprintf(" %s %s\r\n", color.New(m.Color).Sprint("|"), line)) + str.WriteString(fmt.Sprintf(" %s %s\n", color.New(m.Color).Sprint("|"), line)) } return str.String() } From a252f7d87e219f0a414c95830e0334dce3bc2fd1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 9 Aug 2021 11:51:44 -0500 Subject: [PATCH 3/3] Update internal/cmd/tunnel.go Co-authored-by: Jonathan Yu --- internal/cmd/tunnel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/tunnel.go b/internal/cmd/tunnel.go index 373f7ffd..203a9786 100644 --- a/internal/cmd/tunnel.go +++ b/internal/cmd/tunnel.go @@ -70,7 +70,7 @@ coder tunnel my-dev 3000 3000 if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn { color.NoColor = false notAvailableError := clog.Error("workspace not available", - fmt.Sprintf("current status: \"%s\"", workspace.LatestStat.ContainerStatus), + fmt.Sprintf("current status: %q", workspace.LatestStat.ContainerStatus), clog.BlankLine, clog.Tipf("use \"coder workspaces rebuild %s\" to rebuild this workspace", workspace.Name), )