feat: Add support for VS Code and JetBrains Gateway via SSH (#956)

* Improve CLI documentation

* feat: Allow workspace resources to attach multiple agents

This enables a "kubernetes_pod" to attach multiple agents that
could be for multiple services. Each agent is required to have
a unique name, so SSH syntax is:

`coder ssh <workspace>.<agent>`

A resource can have zero agents too, they aren't required.

* Add tree view

* Improve table UI

* feat: Allow workspace resources to attach multiple agents

This enables a "kubernetes_pod" to attach multiple agents that
could be for multiple services. Each agent is required to have
a unique name, so SSH syntax is:

`coder ssh <workspace>.<agent>`

A resource can have zero agents too, they aren't required.

* Rename `tunnel` to `skip-tunnel`

This command was `true` by default, which causes
a confusing user experience.

* Add disclaimer about editing templates

* Add help to template create

* Improve workspace create flow

* Add end-to-end test for config-ssh

* Improve testing of config-ssh

* Fix workspace list

* feat: Add support for VS Code and JetBrains Gateway via SSH

This fixes various bugs that made this not work:
- Incorrect max message size in `peer`
- Incorrect reader buffer size in `peer`
- Lack of SFTP support in `agent`
- Lack of direct-tcpip support in `agent`
- Misuse of command from session. It should always use the shell
- Blocking on SSH session, only allowing one at a time

Fixes #833 too.

* Fix config-ssh command with socat
This commit is contained in:
Kyle Carberry 2022-04-11 19:17:18 -05:00 committed by GitHub
parent fb9dc4f346
commit e8b1a57929
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 77 additions and 51 deletions

View File

@ -158,6 +158,10 @@ jobs:
terraform_version: 1.1.2
terraform_wrapper: false
- name: Install socat
if: runner.os == 'Linux'
run: sudo apt-get install -y socat
- name: Test with Mock Database
shell: bash
env:

View File

@ -21,6 +21,8 @@ import (
"github.com/coder/coder/pty"
"github.com/coder/retry"
"github.com/pkg/sftp"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
@ -121,7 +123,7 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
switch channel.Protocol() {
case "ssh":
a.sshServer.HandleConn(channel.NetConn())
go a.sshServer.HandleConn(channel.NetConn())
default:
a.options.Logger.Warn(ctx, "unhandled protocol from channel",
slog.F("protocol", channel.Protocol()),
@ -146,7 +148,10 @@ func (a *agent) init(ctx context.Context) {
sshLogger := a.options.Logger.Named("ssh-server")
forwardHandler := &ssh.ForwardedTCPHandler{}
a.sshServer = &ssh.Server{
ChannelHandlers: ssh.DefaultChannelHandlers,
ChannelHandlers: map[string]ssh.ChannelHandler{
"direct-tcpip": ssh.DirectTCPIPHandler,
"session": ssh.DefaultSessionHandler,
},
ConnectionFailedCallback: func(conn net.Conn, err error) {
sshLogger.Info(ctx, "ssh connection ended", slog.Error(err))
},
@ -185,61 +190,54 @@ func (a *agent) init(ctx context.Context) {
NoClientAuth: true,
}
},
SubsystemHandlers: map[string]ssh.SubsystemHandler{
"sftp": func(session ssh.Session) {
server, err := sftp.NewServer(session)
if err != nil {
a.options.Logger.Debug(session.Context(), "initialize sftp server", slog.Error(err))
return
}
defer server.Close()
err = server.Serve()
if errors.Is(err, io.EOF) {
return
}
a.options.Logger.Debug(session.Context(), "sftp server exited with error", slog.Error(err))
},
},
}
go a.run(ctx)
}
func (a *agent) handleSSHSession(session ssh.Session) error {
var (
command string
args = []string{}
err error
)
currentUser, err := user.Current()
if err != nil {
return xerrors.Errorf("get current user: %w", err)
}
username := currentUser.Username
// gliderlabs/ssh returns a command slice of zero
// when a shell is requested.
if len(session.Command()) == 0 {
command, err = usershell.Get(username)
if err != nil {
return xerrors.Errorf("get user shell: %w", err)
}
} else {
command = session.Command()[0]
if len(session.Command()) > 1 {
args = session.Command()[1:]
}
shell, err := usershell.Get(username)
if err != nil {
return xerrors.Errorf("get user shell: %w", err)
}
signals := make(chan ssh.Signal)
breaks := make(chan bool)
defer close(signals)
defer close(breaks)
go func() {
for {
select {
case <-session.Context().Done():
return
// Ignore signals and breaks for now!
case <-signals:
case <-breaks:
}
}
}()
// gliderlabs/ssh returns a command slice of zero
// when a shell is requested.
command := session.RawCommand()
if len(session.Command()) == 0 {
command = shell
}
cmd := exec.CommandContext(session.Context(), command, args...)
// OpenSSH executes all commands with the users current shell.
// We replicate that behavior for IDE support.
cmd := exec.CommandContext(session.Context(), shell, "-c", command)
cmd.Env = append(os.Environ(), session.Environ()...)
executablePath, err := os.Executable()
if err != nil {
return xerrors.Errorf("getting os executable: %w", err)
}
cmd.Env = append(session.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND="%s gitssh --"`, executablePath))
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND="%s gitssh --"`, executablePath))
sshPty, windowSize, isPty := session.Pty()
if isPty {
@ -268,7 +266,7 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
}
cmd.Stdout = session
cmd.Stderr = session
cmd.Stderr = session.Stderr()
// This blocks forever until stdin is received if we don't
// use StdinPipe. It's unknown what causes this.
stdinPipe, err := cmd.StdinPipe()
@ -282,8 +280,7 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
if err != nil {
return xerrors.Errorf("start: %w", err)
}
_ = cmd.Wait()
return nil
return cmd.Wait()
}
// isClosed returns whether the API is closed or not.

View File

@ -5,13 +5,16 @@ import (
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/pion/webrtc/v3"
"github.com/pkg/sftp"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/crypto/ssh"
@ -94,6 +97,7 @@ func TestAgent(t *testing.T) {
local, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer local.Close()
tcpAddr, valid = local.Addr().(*net.TCPAddr)
require.True(t, valid)
localPort := tcpAddr.Port
@ -113,6 +117,21 @@ func TestAgent(t *testing.T) {
conn.Close()
<-done
})
t.Run("SFTP", func(t *testing.T) {
t.Parallel()
sshClient, err := setupAgent(t).SSHClient()
require.NoError(t, err)
client, err := sftp.NewClient(sshClient)
require.NoError(t, err)
tempFile := filepath.Join(t.TempDir(), "sftp")
file, err := client.Create(tempFile)
require.NoError(t, err)
err = file.Close()
require.NoError(t, err)
_, err = os.Stat(tempFile)
require.NoError(t, err)
})
}
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {

View File

@ -4,11 +4,12 @@ import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
"github.com/stretchr/testify/require"
)
func TestWorkspaceResources(t *testing.T) {

16
go.mod
View File

@ -48,6 +48,7 @@ require (
github.com/fatih/color v1.13.0
github.com/gliderlabs/ssh v0.3.3
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/httprate v0.5.3
github.com/go-chi/render v1.0.1
github.com/go-playground/validator/v10 v10.10.1
github.com/gohugoio/hugo v0.96.0
@ -60,6 +61,7 @@ require (
github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f
github.com/hashicorp/terraform-exec v0.15.0
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87
github.com/jedib0t/go-pretty/v6 v6.3.0
github.com/justinas/nosurf v1.1.1
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/lib/pq v1.10.5
@ -72,7 +74,9 @@ require (
github.com/pion/transport v0.13.0
github.com/pion/webrtc/v3 v3.1.27
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/sftp v1.13.4
github.com/quasilyte/go-ruleguard/dsl v0.3.19
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.26.1
github.com/spf13/cobra v1.4.0
github.com/spf13/pflag v1.0.5
@ -83,20 +87,19 @@ require (
go.uber.org/atomic v1.9.0
go.uber.org/goleak v1.1.12
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd
golang.org/x/mod v0.5.1
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
google.golang.org/api v0.74.0
google.golang.org/protobuf v1.28.0
gopkg.in/DataDog/dd-trace-go.v1 v1.37.1
nhooyr.io/websocket v1.8.7
storj.io/drpc v0.0.30
)
require github.com/go-chi/httprate v0.5.3
require github.com/jedib0t/go-pretty/v6 v6.3.0
require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
@ -171,6 +174,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.15.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lucas-clemente/quic-go v0.25.1-0.20220307142123-ad1cb27c1b64 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@ -222,7 +226,6 @@ require (
github.com/prometheus/procfs v0.7.3 // indirect
github.com/rivo/tview v0.0.0-20200712113419-c65badfc3d92 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.8.1 // indirect
@ -235,16 +238,13 @@ require (
github.com/zclconf/go-cty v1.10.0 // indirect
github.com/zeebo/errs v1.2.2 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/mod v0.5.1
golang.org/x/net v0.0.0-20220325170049-de3da57026de // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
golang.org/x/tools v0.1.9 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb // indirect
google.golang.org/grpc v1.45.0 // indirect
gopkg.in/DataDog/dd-trace-go.v1 v1.37.1
gopkg.in/coreos/go-oidc.v2 v2.2.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect

3
go.sum
View File

@ -1103,6 +1103,7 @@ github.com/klauspost/crc32 v1.2.0/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3H
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -1438,6 +1439,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg=
github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View File

@ -21,7 +21,7 @@ const (
// For some reason messages larger just don't work...
// This shouldn't be a huge deal for real-world usage.
// See: https://github.com/pion/datachannel/issues/59
maxMessageLength = 32 * 1024 // 32 KB
maxMessageLength = 64 * 1024 // 64 KB
)
// newChannel creates a new channel and initializes it.
@ -145,7 +145,9 @@ func (c *Channel) init() {
if c.opts.Unordered {
c.reader = c.rwc
} else {
c.reader = bufio.NewReader(c.rwc)
// This must be the max message length otherwise a short
// buffer error can occur.
c.reader = bufio.NewReaderSize(c.rwc, maxMessageLength)
}
close(c.opened)
})