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..203a9786 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: %q", 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() +}