feat: Add web terminal with reconnecting TTYs (#1186)

* feat: Add web terminal with reconnecting TTYs

This adds a web terminal that can reconnect to resume sessions!
No more disconnects, and no more bad bufferring!

* Add xstate service

* Add the webpage for accessing a web terminal

* Add terminal page tests

* Use Ticker instead of Timer

* Active Windows mode on Windows
This commit is contained in:
Kyle Carberry 2022-04-29 17:30:10 -05:00 committed by GitHub
parent 23e5636dd0
commit 81577f120a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1448 additions and 39 deletions

View File

@ -1,5 +1,6 @@
{
"cSpell.words": [
"circbuf",
"cliflag",
"cliui",
"coderd",
@ -47,6 +48,7 @@
"ptty",
"ptytest",
"retrier",
"rpty",
"sdkproto",
"Signup",
"stretchr",
@ -60,8 +62,10 @@
"unconvert",
"Untar",
"VMID",
"weblinks",
"webrtc",
"xerrors",
"xstate",
"yamux"
],
"emeraldwalk.runonsave": {

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
@ -12,10 +13,14 @@ import (
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/armon/circbuf"
"github.com/google/uuid"
gsyslog "github.com/hashicorp/go-syslog"
"go.uber.org/atomic"
@ -33,6 +38,11 @@ import (
"golang.org/x/xerrors"
)
type Options struct {
ReconnectingPTYTimeout time.Duration
Logger slog.Logger
}
type Metadata struct {
OwnerEmail string `json:"owner_email"`
OwnerUsername string `json:"owner_username"`
@ -42,13 +52,20 @@ type Metadata struct {
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
func New(dialer Dialer, logger slog.Logger) io.Closer {
func New(dialer Dialer, options *Options) io.Closer {
if options == nil {
options = &Options{}
}
if options.ReconnectingPTYTimeout == 0 {
options.ReconnectingPTYTimeout = 5 * time.Minute
}
ctx, cancelFunc := context.WithCancel(context.Background())
server := &agent{
dialer: dialer,
logger: logger,
closeCancel: cancelFunc,
closed: make(chan struct{}),
dialer: dialer,
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
logger: options.Logger,
closeCancel: cancelFunc,
closed: make(chan struct{}),
}
server.init(ctx)
return server
@ -58,6 +75,9 @@ type agent struct {
dialer Dialer
logger slog.Logger
reconnectingPTYs sync.Map
reconnectingPTYTimeout time.Duration
connCloseWait sync.WaitGroup
closeCancel context.CancelFunc
closeMutex sync.Mutex
@ -196,6 +216,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
switch channel.Protocol() {
case "ssh":
go a.sshServer.HandleConn(channel.NetConn())
case "reconnecting-pty":
go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn())
default:
a.logger.Warn(ctx, "unhandled protocol from channel",
slog.F("protocol", channel.Protocol()),
@ -282,22 +304,25 @@ func (a *agent) init(ctx context.Context) {
go a.run(ctx)
}
func (a *agent) handleSSHSession(session ssh.Session) error {
// createCommand processes raw command input with OpenSSH-like behavior.
// If the rawCommand provided is empty, it will default to the users shell.
// This injects environment variables specified by the user at launch too.
func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) {
currentUser, err := user.Current()
if err != nil {
return xerrors.Errorf("get current user: %w", err)
return nil, xerrors.Errorf("get current user: %w", err)
}
username := currentUser.Username
shell, err := usershell.Get(username)
if err != nil {
return xerrors.Errorf("get user shell: %w", err)
return nil, xerrors.Errorf("get user shell: %w", err)
}
// gliderlabs/ssh returns a command slice of zero
// when a shell is requested.
command := session.RawCommand()
if len(session.Command()) == 0 {
command := rawCommand
if len(command) == 0 {
command = shell
}
@ -307,11 +332,11 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
if runtime.GOOS == "windows" {
caller = "/c"
}
cmd := exec.CommandContext(session.Context(), shell, caller, command)
cmd.Env = append(os.Environ(), session.Environ()...)
cmd := exec.CommandContext(ctx, shell, caller, command)
cmd.Env = append(os.Environ(), env...)
executablePath, err := os.Executable()
if err != nil {
return xerrors.Errorf("getting os executable: %w", err)
return nil, xerrors.Errorf("getting os executable: %w", err)
}
// Git on Windows resolves with UNIX-style paths.
// If using backslashes, it's unable to find the executable.
@ -332,6 +357,14 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
}
}
}
return cmd, nil
}
func (a *agent) handleSSHSession(session ssh.Session) error {
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
if err != nil {
return err
}
sshPty, windowSize, isPty := session.Pty()
if isPty {
@ -381,6 +414,194 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
return cmd.Wait()
}
func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) {
defer conn.Close()
// The ID format is referenced in conn.go.
// <uuid>:<height>:<width>
idParts := strings.Split(rawID, ":")
if len(idParts) != 3 {
a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID))
return
}
id := idParts[0]
// Enforce a consistent format for IDs.
_, err := uuid.Parse(id)
if err != nil {
a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err))
return
}
// Parse the initial terminal dimensions.
height, err := strconv.Atoi(idParts[1])
if err != nil {
a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1]))
return
}
width, err := strconv.Atoi(idParts[2])
if err != nil {
a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2]))
return
}
var rpty *reconnectingPTY
rawRPTY, ok := a.reconnectingPTYs.Load(id)
if ok {
rpty, ok = rawRPTY.(*reconnectingPTY)
if !ok {
a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id))
}
} else {
// Empty command will default to the users shell!
cmd, err := a.createCommand(ctx, "", nil)
if err != nil {
a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err))
return
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptty, process, err := pty.Start(cmd)
if err != nil {
a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id))
}
// Default to buffer 64KB.
circularBuffer, err := circbuf.NewBuffer(64 * 1024)
if err != nil {
a.logger.Warn(ctx, "create circular buffer", slog.Error(err))
return
}
a.closeMutex.Lock()
a.connCloseWait.Add(1)
a.closeMutex.Unlock()
ctx, cancelFunc := context.WithCancel(ctx)
rpty = &reconnectingPTY{
activeConns: make(map[string]net.Conn),
ptty: ptty,
// Timeouts created with an after func can be reset!
timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc),
circularBuffer: circularBuffer,
}
a.reconnectingPTYs.Store(id, rpty)
go func() {
// CommandContext isn't respected for Windows PTYs right now,
// so we need to manually track the lifecycle.
// When the context has been completed either:
// 1. The timeout completed.
// 2. The parent context was canceled.
<-ctx.Done()
_ = process.Kill()
}()
go func() {
// If the process dies randomly, we should
// close the pty.
_, _ = process.Wait()
rpty.Close()
}()
go func() {
buffer := make([]byte, 1024)
for {
read, err := rpty.ptty.Output().Read(buffer)
if err != nil {
// When the PTY is closed, this is triggered.
break
}
part := buffer[:read]
_, err = rpty.circularBuffer.Write(part)
if err != nil {
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id))
break
}
rpty.activeConnsMutex.Lock()
for _, conn := range rpty.activeConns {
_, _ = conn.Write(part)
}
rpty.activeConnsMutex.Unlock()
}
// Cleanup the process, PTY, and delete it's
// ID from memory.
_ = process.Kill()
rpty.Close()
a.reconnectingPTYs.Delete(id)
a.connCloseWait.Done()
}()
}
// Resize the PTY to initial height + width.
err = rpty.ptty.Resize(uint16(height), uint16(width))
if err != nil {
// We can continue after this, it's not fatal!
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
}
// Write any previously stored data for the TTY.
_, err = conn.Write(rpty.circularBuffer.Bytes())
if err != nil {
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err))
return
}
connectionID := uuid.NewString()
// Multiple connections to the same TTY are permitted.
// This could easily be used for terminal sharing, but
// we do it because it's a nice user experience to
// copy/paste a terminal URL and have it _just work_.
rpty.activeConnsMutex.Lock()
rpty.activeConns[connectionID] = conn
rpty.activeConnsMutex.Unlock()
// Resetting this timeout prevents the PTY from exiting.
rpty.timeout.Reset(a.reconnectingPTYTimeout)
ctx, cancelFunc := context.WithCancel(ctx)
defer cancelFunc()
heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2)
defer heartbeat.Stop()
go func() {
// Keep updating the activity while this
// connection is alive!
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
}
rpty.timeout.Reset(a.reconnectingPTYTimeout)
}
}()
defer func() {
// After this connection ends, remove it from
// the PTYs active connections. If it isn't
// removed, all PTY data will be sent to it.
rpty.activeConnsMutex.Lock()
delete(rpty.activeConns, connectionID)
rpty.activeConnsMutex.Unlock()
}()
decoder := json.NewDecoder(conn)
var req ReconnectingPTYRequest
for {
err = decoder.Decode(&req)
if xerrors.Is(err, io.EOF) {
return
}
if err != nil {
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err))
return
}
_, err = rpty.ptty.Input().Write([]byte(req.Data))
if err != nil {
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err))
return
}
// Check if a resize needs to happen!
if req.Height == 0 || req.Width == 0 {
continue
}
err = rpty.ptty.Resize(req.Height, req.Width)
if err != nil {
// We can continue after this, it's not fatal!
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
}
}
}
// isClosed returns whether the API is closed or not.
func (a *agent) isClosed() bool {
select {
@ -403,3 +624,25 @@ func (a *agent) Close() error {
a.connCloseWait.Wait()
return nil
}
type reconnectingPTY struct {
activeConnsMutex sync.Mutex
activeConns map[string]net.Conn
circularBuffer *circbuf.Buffer
timeout *time.Timer
ptty pty.PTY
}
// Close ends all connections to the reconnecting
// PTY and clear the circular buffer.
func (r *reconnectingPTY) Close() {
r.activeConnsMutex.Lock()
defer r.activeConnsMutex.Unlock()
for _, conn := range r.activeConns {
_ = conn.Close()
}
_ = r.ptty.Close()
r.circularBuffer.Reset()
r.timeout.Stop()
}

View File

@ -2,6 +2,7 @@ package agent_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
@ -14,6 +15,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/pion/webrtc/v3"
"github.com/pkg/sftp"
"github.com/stretchr/testify/require"
@ -131,7 +133,7 @@ func TestAgent(t *testing.T) {
t.Run("SFTP", func(t *testing.T) {
t.Parallel()
sshClient, err := setupAgent(t, agent.Metadata{}).SSHClient()
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
require.NoError(t, err)
client, err := sftp.NewClient(sshClient)
require.NoError(t, err)
@ -168,7 +170,7 @@ func TestAgent(t *testing.T) {
content := "somethingnice"
setupAgent(t, agent.Metadata{
StartupScript: "echo " + content + " > " + tempPath,
})
}, 0)
var gotContent string
require.Eventually(t, func() bool {
content, err := os.ReadFile(tempPath)
@ -188,10 +190,54 @@ func TestAgent(t *testing.T) {
}, 15*time.Second, 100*time.Millisecond)
require.Equal(t, content, strings.TrimSpace(gotContent))
})
t.Run("ReconnectingPTY", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
// This might be our implementation, or ConPTY itself.
// It's difficult to find extensive tests for it, so
// it seems like it could be either.
t.Skip("ConPTY appears to be inconsistent on Windows.")
}
conn := setupAgent(t, agent.Metadata{}, 0)
id := uuid.NewString()
netConn, err := conn.ReconnectingPTY(id, 100, 100)
require.NoError(t, err)
data, err := json.Marshal(agent.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = netConn.Write(data)
require.NoError(t, err)
findEcho := func() {
for {
read, err := netConn.Read(data)
require.NoError(t, err)
if strings.Contains(string(data[:read]), "test") {
break
}
}
}
// Once for typing the command...
findEcho()
// And another time for the actual output.
findEcho()
_ = netConn.Close()
netConn, err = conn.ReconnectingPTY(id, 100, 100)
require.NoError(t, err)
// Same output again!
findEcho()
findEcho()
})
}
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
agentConn := setupAgent(t, agent.Metadata{})
agentConn := setupAgent(t, agent.Metadata{}, 0)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
go func() {
@ -220,19 +266,22 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
}
func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session {
sshClient, err := setupAgent(t, options).SSHClient()
sshClient, err := setupAgent(t, options, 0).SSHClient()
require.NoError(t, err)
session, err := sshClient.NewSession()
require.NoError(t, err)
return session
}
func setupAgent(t *testing.T, options agent.Metadata) *agent.Conn {
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
client, server := provisionersdk.TransportPipe()
closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) {
listener, err := peerbroker.Listen(server, nil)
return options, listener, err
}, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
return metadata, listener, err
}, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
ReconnectingPTYTimeout: ptyTimeout,
})
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()

View File

@ -2,6 +2,7 @@ package agent
import (
"context"
"fmt"
"net"
"golang.org/x/crypto/ssh"
@ -11,6 +12,14 @@ import (
"github.com/coder/coder/peerbroker/proto"
)
// ReconnectingPTYRequest is sent from the client to the server
// to pipe data to a PTY.
type ReconnectingPTYRequest struct {
Data string `json:"data"`
Height uint16 `json:"height"`
Width uint16 `json:"width"`
}
// Conn wraps a peer connection with helper functions to
// communicate with the agent.
type Conn struct {
@ -20,6 +29,18 @@ type Conn struct {
*peer.Conn
}
// ReconnectingPTY returns a connection serving a TTY that can
// be reconnected to via ID.
func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) {
channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{
Protocol: "reconnecting-pty",
})
if err != nil {
return nil, xerrors.Errorf("pty: %w", err)
}
return channel.NetConn(), nil
}
// SSH dials the built-in SSH server.
func (c *Conn) SSH() (net.Conn, error) {
channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{

View File

@ -125,7 +125,9 @@ func workspaceAgent() *cobra.Command {
return xerrors.Errorf("writing agent url to config: %w", err)
}
closer := agent.New(client.ListenWorkspaceAgent, logger)
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
Logger: logger,
})
<-cmd.Context().Done()
return closer.Close()
},

View File

@ -71,7 +71,9 @@ func TestConfigSSH(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil))
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})

View File

@ -69,7 +69,9 @@ func TestSSH(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
@ -112,7 +114,9 @@ func TestSSH(t *testing.T) {
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = agentToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})

View File

@ -228,6 +228,7 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/", api.workspaceAgent)
r.Get("/dial", api.workspaceAgentDial)
r.Get("/turn", api.workspaceAgentTurn)
r.Get("/pty", api.workspaceAgentPTY)
r.Get("/iceservers", api.workspaceAgentICEServers)
})
})

View File

@ -10,6 +10,7 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
@ -19,7 +20,9 @@ import (
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/turnconn"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker"
"github.com/coder/coder/peerbroker/proto"
"github.com/coder/coder/provisionersdk"
@ -59,9 +62,7 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) {
return
}
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("accept websocket: %s", err),
@ -320,6 +321,135 @@ func (api *api) workspaceAgentTurn(rw http.ResponseWriter, r *http.Request) {
api.Logger.Debug(r.Context(), "completed turn connection", slog.F("remote-address", r.RemoteAddr), slog.F("local-address", localAddress))
}
// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket.
// This is used for the web terminal.
func (api *api) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
api.websocketWaitMutex.Lock()
api.websocketWaitGroup.Add(1)
api.websocketWaitMutex.Unlock()
defer api.websocketWaitGroup.Done()
workspaceAgent := httpmw.WorkspaceAgentParam(r)
apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("convert workspace agent: %s", err),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{
Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status),
})
return
}
reconnect, err := uuid.Parse(r.URL.Query().Get("reconnect"))
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("reconnection must be a uuid: %s", err),
})
return
}
height, err := strconv.Atoi(r.URL.Query().Get("height"))
if err != nil {
height = 80
}
width, err := strconv.Atoi(r.URL.Query().Get("width"))
if err != nil {
width = 80
}
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("accept websocket: %s", err),
})
return
}
defer func() {
_ = conn.Close(websocket.StatusNormalClosure, "ended")
}()
// Accept text connections, because it's more developer friendly.
wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary)
agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID)
if err != nil {
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
return
}
defer agentConn.Close()
ptNetConn, err := agentConn.ReconnectingPTY(reconnect.String(), uint16(height), uint16(width))
if err != nil {
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
return
}
defer ptNetConn.Close()
// Pipe the ends together!
go func() {
_, _ = io.Copy(wsNetConn, ptNetConn)
}()
_, _ = io.Copy(ptNetConn, wsNetConn)
}
// dialWorkspaceAgent connects to a workspace agent by ID.
func (api *api) dialWorkspaceAgent(r *http.Request, agentID uuid.UUID) (*agent.Conn, error) {
client, server := provisionersdk.TransportPipe()
go func() {
_ = peerbroker.ProxyListen(r.Context(), server, peerbroker.ProxyOptions{
ChannelID: agentID.String(),
Logger: api.Logger.Named("peerbroker-proxy-dial"),
Pubsub: api.Pubsub,
})
_ = client.Close()
_ = server.Close()
}()
peerClient := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
stream, err := peerClient.NegotiateConnection(r.Context())
if err != nil {
return nil, xerrors.Errorf("negotiate: %w", err)
}
options := &peer.ConnOptions{}
options.SettingEngine.SetSrflxAcceptanceMinWait(0)
options.SettingEngine.SetRelayAcceptanceMinWait(0)
// Use the ProxyDialer for the TURN server.
// This is required for connections where P2P is not enabled.
options.SettingEngine.SetICEProxyDialer(turnconn.ProxyDialer(func() (c net.Conn, err error) {
clientPipe, serverPipe := net.Pipe()
go func() {
<-r.Context().Done()
_ = clientPipe.Close()
_ = serverPipe.Close()
}()
localAddress, _ := r.Context().Value(http.LocalAddrContextKey).(*net.TCPAddr)
remoteAddress := &net.TCPAddr{
IP: net.ParseIP(r.RemoteAddr),
}
// By default requests have the remote address and port.
host, port, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return nil, xerrors.Errorf("split remote address: %w", err)
}
remoteAddress.IP = net.ParseIP(host)
remoteAddress.Port, err = strconv.Atoi(port)
if err != nil {
return nil, xerrors.Errorf("convert remote port: %w", err)
}
api.TURNServer.Accept(clientPipe, remoteAddress, localAddress)
return serverPipe, nil
}))
peerConn, err := peerbroker.Dial(stream, append(api.ICEServers, turnconn.Proxy), options)
if err != nil {
return nil, xerrors.Errorf("dial: %w", err)
}
return &agent.Conn{
Negotiator: peerClient,
Conn: peerConn,
}, nil
}
func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) {
var envs map[string]string
if dbAgent.EnvironmentVariables.Valid {

View File

@ -2,6 +2,8 @@ package coderd_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
@ -90,7 +92,9 @@ func TestWorkspaceAgentListen(t *testing.T) {
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug))
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
@ -138,7 +142,9 @@ func TestWorkspaceAgentTURN(t *testing.T) {
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil))
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
@ -156,3 +162,80 @@ func TestWorkspaceAgentTURN(t *testing.T) {
_, err = conn.Ping()
require.NoError(t, err)
}
func TestWorkspaceAgentPTY(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
daemonCloser := coderdtest.NewProvisionerDaemon(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
daemonCloser.Close()
agentClient := codersdk.New(client.URL)
agentClient.SessionToken = authToken
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
Logger: slogtest.Make(t, nil),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
conn, err := client.WorkspaceAgentReconnectingPTY(context.Background(), resources[0].Agents[0].ID, uuid.New(), 80, 80)
require.NoError(t, err)
defer conn.Close()
// First attempt to resize the TTY.
// The websocket will close if it fails!
data, err := json.Marshal(agent.ReconnectingPTYRequest{
Height: 250,
Width: 250,
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
data, err = json.Marshal(agent.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
findEcho := func() {
for {
read, err := conn.Read(data)
require.NoError(t, err)
if strings.Contains(string(data[:read]), "test") {
return
}
}
}
findEcho()
findEcho()
}

View File

@ -338,6 +338,37 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
}
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
// Responses are PTY output that can be rendered.
func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, reconnect uuid.UUID, height, width int) (net.Conn, error) {
serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty?reconnect=%s&height=%d&width=%d", agentID, reconnect, height, width))
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: httpmw.AuthCookie,
Value: c.SessionToken,
}})
httpClient := &http.Client{
Jar: jar,
}
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
})
if err != nil {
if res == nil {
return nil, err
}
return nil, readBodyAsError(res)
}
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}
func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer {
return turnconn.ProxyDialer(func() (net.Conn, error) {
turnURL, err := c.URL.Parse(path)

1
go.mod
View File

@ -43,6 +43,7 @@ require (
cdr.dev/slog v1.4.1
cloud.google.com/go/compute v1.6.1
github.com/AlecAivazis/survey/v2 v2.3.4
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/awalterschulze/gographviz v2.0.3+incompatible
github.com/bgentry/speakeasy v0.1.0
github.com/briandowns/spinner v1.18.1

2
go.sum
View File

@ -189,6 +189,8 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs=

View File

@ -46,8 +46,8 @@ func (p *otherPty) Resize(height uint16, width uint16) error {
p.mutex.Lock()
defer p.mutex.Unlock()
return pty.Setsize(p.pty, &pty.Winsize{
Rows: width,
Cols: height,
Rows: height,
Cols: width,
})
}

View File

@ -28,6 +28,9 @@ module.exports = {
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes", "/e2e/"],
moduleDirectories: ["node_modules", "<rootDir>"],
moduleNameMapper: {
"\\.css$": "<rootDir>/src/testHelpers/styleMock.ts",
},
},
{
displayName: "lint",

View File

@ -41,7 +41,13 @@
"react-dom": "17.0.2",
"react-router-dom": "6.3.0",
"swr": "1.2.2",
"uuid": "^8.3.2",
"xstate": "4.31.0",
"xterm": "^4.18.0",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-web-links": "^0.5.1",
"xterm-addon-webgl": "^0.11.4",
"xterm-for-react": "^1.0.4",
"yup": "0.32.11"
},
"devDependencies": {
@ -60,6 +66,7 @@
"@types/react": "17.0.44",
"@types/react-dom": "17.0.16",
"@types/superagent": "4.1.15",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "5.21.0",
"@typescript-eslint/parser": "5.21.0",
"@xstate/cli": "0.1.7",
@ -80,8 +87,10 @@
"eslint-plugin-react-hooks": "4.5.0",
"html-webpack-plugin": "5.5.0",
"jest": "27.5.1",
"jest-canvas-mock": "^2.4.0",
"jest-junit": "13.2.0",
"jest-runner-eslint": "1.0.0",
"jest-websocket-mock": "^2.3.0",
"mini-css-extract-plugin": "2.6.0",
"msw": "0.39.2",
"prettier": "2.6.2",

View File

@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
import { TerminalPage } from "./pages/TerminalPage/TerminalPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
@ -126,6 +127,19 @@ export const AppRouter: React.FC = () => (
<Route path="linked-accounts" element={<LinkedAccountsPage />} />
</Route>
<Route path=":username">
<Route path=":workspace">
<Route
path="terminal"
element={
<RequireAuth>
<TerminalPage />
</RequireAuth>
}
/>
</Route>
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}

View File

@ -80,6 +80,27 @@ export const getUsers = async (): Promise<TypesGen.User[]> => {
return response.data
}
export const getOrganizations = async (): Promise<Types.Organization[]> => {
const response = await axios.get<Types.Organization[]>("/api/v2/users/me/organizations")
return response.data
}
export const getWorkspace = async (
organizationID: string,
username = "me",
workspaceName: string,
): Promise<Types.Workspace> => {
const response = await axios.get<Types.Workspace>(
`/api/v2/organizations/${organizationID}/workspaces/${username}/${workspaceName}`,
)
return response.data
}
export const getWorkspaceResources = async (workspaceBuildID: string): Promise<Types.WorkspaceResource[]> => {
const response = await axios.get<Types.WorkspaceResource[]>(`/api/v2/workspacebuilds/${workspaceBuildID}/resources`)
return response.data
}
export const createUser = async (user: Types.CreateUserRequest): Promise<TypesGen.User> => {
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
return response.data

View File

@ -63,9 +63,10 @@ export interface CreateWorkspaceRequest {
template_id: string
}
/**
* @remarks Keep in sync with codersdk/workspaces.go
*/
export interface WorkspaceBuild {
id: string
}
export interface Workspace {
id: string
created_at: string
@ -75,6 +76,18 @@ export interface Workspace {
name: string
autostart_schedule: string
autostop_schedule: string
latest_build: WorkspaceBuild
}
export interface WorkspaceResource {
id: string
agents?: WorkspaceAgent[]
}
export interface WorkspaceAgent {
id: string
name: string
operating_system: string
}
export interface APIKeyResponse {
@ -100,3 +113,9 @@ export interface UpdateProfileRequest {
readonly username: string
readonly email: string
}
export interface ReconnectingPTYRequest {
readonly data?: string
readonly height?: number
readonly width?: number
}

View File

@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import useSWR from "swr"
import { Organization, Template, Workspace } from "../../../../api/types"
import { Organization, Template, Workspace, WorkspaceBuild } from "../../../../api/types"
import { EmptyState } from "../../../../components/EmptyState/EmptyState"
import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary"
import { Header } from "../../../../components/Header/Header"
@ -64,7 +64,7 @@ export const TemplatePage: React.FC = () => {
{
key: "name",
name: "Name",
renderer: (nameField: string, workspace: Workspace) => {
renderer: (nameField: string | WorkspaceBuild, workspace: Workspace) => {
return <Link to={`/workspaces/${workspace.id}`}>{nameField}</Link>
},
},

View File

@ -0,0 +1,149 @@
import { waitFor } from "@testing-library/react"
import "jest-canvas-mock"
import WS from "jest-websocket-mock"
import { rest } from "msw"
import React from "react"
import { Route, Routes } from "react-router-dom"
import { TextDecoder, TextEncoder } from "util"
import { ReconnectingPTYRequest } from "../../api/types"
import { history, MockWorkspaceAgent, render } from "../../testHelpers"
import { server } from "../../testHelpers/server"
import { Language, TerminalPage } from "./TerminalPage"
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
Object.defineProperty(window, "TextEncoder", {
value: TextEncoder,
})
const renderTerminal = () => {
return render(
<Routes>
<Route path="/:username/:workspace/terminal" element={<TerminalPage renderer="dom" />} />
</Routes>,
)
}
const expectTerminalText = (container: HTMLElement, text: string) => {
return waitFor(() => {
const elements = container.getElementsByClassName("xterm-rows")
if (elements.length < 1) {
throw new Error("no xterm-rows")
}
const row = elements[0] as HTMLDivElement
if (!row.textContent) {
throw new Error("no text content")
}
expect(row.textContent).toContain(text)
})
}
describe("TerminalPage", () => {
beforeEach(() => {
history.push("/some-user/my-workspace/terminal")
})
it("shows an error if fetching organizations fails", async () => {
// Given
server.use(
rest.get("/api/v2/users/me/organizations", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: "nope" }))
}),
)
// When
const { container } = renderTerminal()
// Then
await expectTerminalText(container, Language.organizationsErrorMessagePrefix)
})
it("shows an error if fetching workspace fails", async () => {
// Given
server.use(
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ id: "workspace-id" }))
}),
)
// When
const { container } = renderTerminal()
// Then
await expectTerminalText(container, Language.workspaceErrorMessagePrefix)
})
it("shows an error if fetching workspace agent fails", async () => {
// Given
server.use(
rest.get("/api/v2/workspacebuilds/:workspaceId/resources", (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: "nope" }))
}),
)
// When
const { container } = renderTerminal()
// Then
await expectTerminalText(container, Language.workspaceAgentErrorMessagePrefix)
})
it("shows an error if the websocket fails", async () => {
// Given
server.use(
rest.get("/api/v2/workspaceagents/:agentId/pty", (req, res, ctx) => {
return res(ctx.status(500), ctx.json({}))
}),
)
// When
const { container } = renderTerminal()
// Then
await expectTerminalText(container, Language.websocketErrorMessagePrefix)
})
it("renders data from the backend", async () => {
// Given
const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty")
const text = "something to render"
// When
const { container } = renderTerminal()
// Then
await server.connected
server.send(text)
await expectTerminalText(container, text)
server.close()
})
it("resizes on connect", async () => {
// Given
const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty")
// When
renderTerminal()
// Then
await server.connected
const msg = await server.nextMessage
const req: ReconnectingPTYRequest = JSON.parse(new TextDecoder().decode(msg as Uint8Array))
expect(req.height).toBeGreaterThan(0)
expect(req.width).toBeGreaterThan(0)
server.close()
})
})

View File

@ -0,0 +1,249 @@
import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import React from "react"
import { useLocation, useNavigate, useParams } from "react-router-dom"
import { v4 as uuidv4 } from "uuid"
import * as XTerm from "xterm"
import { FitAddon } from "xterm-addon-fit"
import { WebLinksAddon } from "xterm-addon-web-links"
import "xterm/css/xterm.css"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { terminalMachine } from "../../xServices/terminal/terminalXService"
export const Language = {
organizationsErrorMessagePrefix: "Unable to fetch organizations: ",
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
websocketErrorMessagePrefix: "WebSocket failed: ",
}
export const TerminalPage: React.FC<{
readonly renderer?: XTerm.RendererType
}> = ({ renderer }) => {
const location = useLocation()
const navigate = useNavigate()
const styles = useStyles()
const { username, workspace } = useParams()
const xtermRef = React.useRef<HTMLDivElement>(null)
const [terminal, setTerminal] = React.useState<XTerm.Terminal | null>(null)
const [fitAddon, setFitAddon] = React.useState<FitAddon | null>(null)
// The reconnection token is a unique token that identifies
// a terminal session. It's generated by the client to reduce
// a round-trip, and must be a UUIDv4.
const [reconnectionToken] = React.useState<string>(() => {
const search = new URLSearchParams(location.search)
return search.get("reconnect") ?? uuidv4()
})
const [terminalState, sendEvent] = useMachine(terminalMachine, {
context: {
reconnection: reconnectionToken,
workspaceName: workspace,
username: username,
},
actions: {
readMessage: (_, event) => {
if (typeof event.data === "string") {
// This exclusively occurs when testing.
// "jest-websocket-mock" doesn't support ArrayBuffer.
terminal?.write(event.data)
} else {
terminal?.write(new Uint8Array(event.data))
}
},
},
})
const isConnected = terminalState.matches("connected")
const isDisconnected = terminalState.matches("disconnected")
const { organizationsError, workspaceError, workspaceAgentError, workspaceAgent, websocketError } =
terminalState.context
// Create the terminal!
React.useEffect(() => {
if (!xtermRef.current) {
return
}
const terminal = new XTerm.Terminal({
allowTransparency: true,
disableStdin: false,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 16,
theme: {
// This is a slight off-black.
// It's really easy on the eyes!
background: "#1F1F1F",
},
rendererType: renderer,
})
const fitAddon = new FitAddon()
setFitAddon(fitAddon)
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.onData((data) => {
sendEvent({
type: "WRITE",
request: {
data: data,
},
})
})
terminal.onResize((event) => {
sendEvent({
type: "WRITE",
request: {
height: event.rows,
width: event.cols,
},
})
})
setTerminal(terminal)
terminal.open(xtermRef.current)
const listener = () => {
// This will trigger a resize event on the terminal.
fitAddon.fit()
}
window.addEventListener("resize", listener)
return () => {
window.removeEventListener("resize", listener)
terminal.dispose()
}
}, [renderer, sendEvent, xtermRef])
// Triggers the initial terminal connection using
// the reconnection token and workspace name found
// from the router.
React.useEffect(() => {
const search = new URLSearchParams(location.search)
search.set("reconnect", reconnectionToken)
navigate(
{
search: search.toString(),
},
{
replace: true,
},
)
}, [location.search, navigate, reconnectionToken])
// Apply terminal options based on connection state.
React.useEffect(() => {
if (!terminal || !fitAddon) {
return
}
// We have to fit twice here. It's unknown why, but
// the first fit will overflow slightly in some
// scenarios. Applying a second fit resolves this.
fitAddon.fit()
fitAddon.fit()
if (!isConnected) {
// Disable user input when not connected.
terminal.options = {
disableStdin: true,
}
if (organizationsError instanceof Error) {
terminal.writeln(Language.organizationsErrorMessagePrefix + organizationsError.message)
}
if (workspaceError instanceof Error) {
terminal.writeln(Language.workspaceErrorMessagePrefix + workspaceError.message)
}
if (workspaceAgentError instanceof Error) {
terminal.writeln(Language.workspaceAgentErrorMessagePrefix + workspaceAgentError.message)
}
if (websocketError instanceof Error) {
terminal.writeln(Language.websocketErrorMessagePrefix + websocketError.message)
}
return
}
// The terminal should be cleared on each reconnect
// because all data is re-rendered from the backend.
terminal.clear()
// Focusing on connection allows users to reload the
// page and start typing immediately.
terminal.focus()
terminal.options = {
disableStdin: false,
windowsMode: workspaceAgent?.operating_system === "windows",
}
// Update the terminal size post-fit.
sendEvent({
type: "WRITE",
request: {
height: terminal.rows,
width: terminal.cols,
},
})
}, [
workspaceError,
organizationsError,
workspaceAgentError,
websocketError,
workspaceAgent,
terminal,
fitAddon,
isConnected,
sendEvent,
])
return (
<>
{/* This overlay makes it more obvious that the terminal is disconnected. */}
{/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */}
<div className={`${styles.overlay} ${isDisconnected ? "" : "connected"}`}>
<span>Disconnected</span>
</div>
<div className={styles.terminal} ref={xtermRef} data-testid="terminal" />
</>
)
}
const useStyles = makeStyles(() => ({
overlay: {
position: "absolute",
pointerEvents: "none",
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 1,
alignItems: "center",
justifyContent: "center",
display: "flex",
color: "white",
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 18,
backgroundColor: "rgba(0, 0, 0, 0.2)",
"&.connected": {
opacity: 0,
},
},
terminal: {
width: "100vw",
height: "100vh",
overflow: "hidden",
// These styles attempt to mimic the VS Code scrollbar.
"& .xterm": {
padding: 4,
width: "100vw",
height: "100vh",
},
"& .xterm-viewport": {
// This is required to force full-width on the terminal.
// Otherwise there's a small white bar to the right of the scrollbar.
width: "auto !important",
},
"& .xterm-viewport::-webkit-scrollbar": {
width: "10px",
},
"& .xterm-viewport::-webkit-scrollbar-track": {
backgroundColor: "inherit",
},
"& .xterm-viewport::-webkit-scrollbar-thumb": {
minHeight: 20,
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
},
}))

View File

@ -6,7 +6,9 @@ import {
UserAgent,
UserResponse,
Workspace,
WorkspaceAgent,
WorkspaceAutostartRequest,
WorkspaceResource,
} from "../api/types"
import { AuthMethods } from "../api/typesGenerated"
@ -87,6 +89,20 @@ export const MockWorkspace: Workspace = {
owner_id: MockUser.id,
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
latest_build: {
id: "test-workspace-build",
},
}
export const MockWorkspaceAgent: WorkspaceAgent = {
id: "test-workspace-agent",
name: "a-workspace-agent",
operating_system: "linux",
}
export const MockWorkspaceResource: WorkspaceResource = {
id: "test-workspace-resource",
agents: [MockWorkspaceAgent],
}
export const MockUserAgent: UserAgent = {

View File

@ -30,6 +30,9 @@ export const handlers = [
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
rest.get("/api/v2/users/me/organizations", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockOrganization]))
}),
rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))
}),
@ -50,6 +53,9 @@ export const handlers = [
}),
// workspaces
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
@ -59,4 +65,9 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
return res(ctx.status(200))
}),
// workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockWorkspaceResource]))
}),
]

View File

@ -0,0 +1 @@
export default {}

View File

@ -0,0 +1,275 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api"
import * as Types from "../../api/types"
export interface TerminalContext {
organizationsError?: Error | unknown
organizations?: Types.Organization[]
workspaceError?: Error | unknown
workspace?: Types.Workspace
workspaceAgent?: Types.WorkspaceAgent
workspaceAgentError?: Error | unknown
websocket?: WebSocket
websocketError?: Error | unknown
// Assigned by connecting!
username?: string
workspaceName?: string
reconnection?: string
}
export type TerminalEvent =
| { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string }
| { type: "WRITE"; request: Types.ReconnectingPTYRequest }
| { type: "READ"; data: ArrayBuffer }
| { type: "DISCONNECT" }
export const terminalMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5FKQd27OJTNVAA */
createMachine(
{
tsTypes: {} as import("./terminalXService.typegen").Typegen0,
schema: {
context: {} as TerminalContext,
events: {} as TerminalEvent,
services: {} as {
getOrganizations: {
data: Types.Organization[]
}
getWorkspace: {
data: Types.Workspace
}
getWorkspaceAgent: {
data: Types.WorkspaceAgent
}
connect: {
data: WebSocket
}
},
},
id: "terminalState",
initial: "gettingOrganizations",
states: {
gettingOrganizations: {
invoke: {
src: "getOrganizations",
id: "getOrganizations",
onDone: [
{
actions: ["assignOrganizations", "clearOrganizationsError"],
target: "gettingWorkspace",
},
],
onError: [
{
actions: "assignOrganizationsError",
target: "disconnected",
},
],
},
tags: "loading",
},
gettingWorkspace: {
invoke: {
src: "getWorkspace",
id: "getWorkspace",
onDone: [
{
actions: ["assignWorkspace", "clearWorkspaceError"],
target: "gettingWorkspaceAgent",
},
],
onError: [
{
actions: "assignWorkspaceError",
target: "disconnected",
},
],
},
},
gettingWorkspaceAgent: {
invoke: {
src: "getWorkspaceAgent",
id: "getWorkspaceAgent",
onDone: [
{
actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"],
target: "connecting",
},
],
onError: [
{
actions: "assignWorkspaceAgentError",
target: "disconnected",
},
],
},
},
connecting: {
invoke: {
src: "connect",
id: "connect",
onDone: [
{
actions: ["assignWebsocket", "clearWebsocketError"],
target: "connected",
},
],
onError: [
{
actions: "assignWebsocketError",
target: "disconnected",
},
],
},
},
connected: {
on: {
WRITE: {
actions: "sendMessage",
},
READ: {
actions: "readMessage",
},
DISCONNECT: {
actions: "disconnect",
target: "disconnected",
},
},
},
disconnected: {
on: {
CONNECT: {
actions: "assignConnection",
target: "gettingOrganizations",
},
},
},
},
},
{
services: {
getOrganizations: API.getOrganizations,
getWorkspace: async (context) => {
if (!context.organizations || !context.workspaceName) {
throw new Error("organizations or workspace not set")
}
return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName)
},
getWorkspaceAgent: async (context) => {
if (!context.workspace || !context.workspaceName) {
throw new Error("workspace or workspace name is not set")
}
// The workspace name is in the format:
// <workspace name>[.<agent name>]
// The workspace agent is entirely optional.
const workspaceNameParts = context.workspaceName.split(".")
const agentName = workspaceNameParts[1]
const resources = await API.getWorkspaceResources(context.workspace.latest_build.id)
const agent = resources
.map((resource) => {
if (!resource.agents || resource.agents.length < 1) {
return
}
if (!agentName) {
return resource.agents[0]
}
return resource.agents.find((agent) => agent.name === agentName)
})
.filter((a) => a)[0]
if (!agent) {
throw new Error("no agent found with id")
}
return agent
},
connect: (context) => (send) => {
return new Promise<WebSocket>((resolve, reject) => {
if (!context.workspaceAgent) {
return reject("workspace agent is not set")
}
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}`,
)
socket.binaryType = "arraybuffer"
socket.addEventListener("open", () => {
resolve(socket)
})
socket.addEventListener("error", () => {
reject(new Error("socket errored"))
})
socket.addEventListener("close", () => {
send({
type: "DISCONNECT",
})
})
socket.addEventListener("message", (event) => {
send({
type: "READ",
data: event.data,
})
})
})
},
},
actions: {
assignConnection: assign((context, event) => ({
...context,
reconnection: event.reconnection ?? context.reconnection,
workspaceName: event.workspaceName ?? context.workspaceName,
})),
assignOrganizations: assign({
organizations: (_, event) => event.data,
}),
assignOrganizationsError: assign({
organizationsError: (_, event) => event.data,
}),
clearOrganizationsError: assign((context) => ({
...context,
organizationsError: undefined,
})),
assignWorkspace: assign({
workspace: (_, event) => event.data,
}),
assignWorkspaceError: assign({
workspaceError: (_, event) => event.data,
}),
clearWorkspaceError: assign((context) => ({
...context,
workspaceError: undefined,
})),
assignWorkspaceAgent: assign({
workspaceAgent: (_, event) => event.data,
}),
assignWorkspaceAgentError: assign({
workspaceAgentError: (_, event) => event.data,
}),
clearWorkspaceAgentError: assign((context: TerminalContext) => ({
...context,
workspaceAgentError: undefined,
})),
assignWebsocket: assign({
websocket: (_, event) => event.data,
}),
assignWebsocketError: assign({
websocketError: (_, event) => event.data,
}),
clearWebsocketError: assign((context: TerminalContext) => ({
...context,
webSocketError: undefined,
})),
sendMessage: (context, event) => {
if (!context.websocket) {
throw new Error("websocket doesn't exist")
}
context.websocket.send(new TextEncoder().encode(JSON.stringify(event.request)))
},
disconnect: (context: TerminalContext) => {
// Code 1000 is a successful exit!
context.websocket?.close(1000)
},
},
},
)

View File

@ -60,7 +60,10 @@ const config: Configuration = {
hot: true,
port: process.env.PORT || 8080,
proxy: {
"/api": "http://localhost:3000",
"/api": {
target: "http://localhost:3000",
ws: true,
},
},
static: ["./static"],
},

View File

@ -3194,6 +3194,11 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
"@types/uuid@^8.3.4":
version "8.3.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/webpack-env@^1.16.0":
version "1.16.3"
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a"
@ -5083,7 +5088,7 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
color-name@^1.0.0, color-name@~1.1.4:
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@ -5586,6 +5591,11 @@ cssesc@^3.0.0:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
cssfontparser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=
cssnano-preset-default@^5.2.5:
version "5.2.5"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.5.tgz#267ded811a3e1664d78707f5355fcd89feeb38ac"
@ -8510,6 +8520,14 @@ iterate-value@^1.0.2:
es-get-iterator "^1.0.2"
iterate-iterator "^1.0.1"
jest-canvas-mock@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341"
integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==
dependencies:
cssfontparser "^1.2.1"
moo-color "^1.0.2"
jest-changed-files@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5"
@ -8592,7 +8610,7 @@ jest-config@^27.5.1:
slash "^3.0.0"
strip-json-comments "^3.1.1"
jest-diff@^27.2.5, jest-diff@^27.5.1:
jest-diff@^27.0.2, jest-diff@^27.2.5, jest-diff@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
@ -8973,6 +8991,14 @@ jest-watcher@^27.5.1:
jest-util "^27.5.1"
string-length "^4.0.1"
jest-websocket-mock@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.3.0.tgz#317e7d7f8ba54ba632a7300777b02b7ebb606845"
integrity sha512-kXhRRApRdT4hLG/4rhsfcR0Ke0OzqIsDj0P5t0dl5aiAftShSgoRqp/0pyjS5bh+b9GrIzmfkrV2cn9LxxvSvA==
dependencies:
jest-diff "^27.0.2"
mock-socket "^9.1.0"
jest-worker@^25.1.0:
version "25.5.0"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1"
@ -9831,6 +9857,18 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mock-socket@^9.1.0:
version "9.1.3"
resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.3.tgz#bcb106c6b345001fa7619466fcf2f8f5a156b10f"
integrity sha512-uz8lx8c5wuJYJ21f5UtovqpV0+KJuVwE7cVOLNhrl2QW/CvmstOLRfjXnLSbfFHZtJtiaSGQu0oCJA8SmRcK6A==
moo-color@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
dependencies:
color-name "^1.1.4"
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@ -14129,6 +14167,34 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
xterm-addon-fit@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"
integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==
xterm-addon-web-links@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.5.1.tgz#73bfa3ed567af98fba947f638bd12093ee2a0bc6"
integrity sha512-dBjbOIrCNmxAcUQkkSrKj9BM6yLpmqUpZ9SOCUuZe/sznPl4d8OBZQClK7VcdZ0vf0+5i5Fce2rUUrew/XTZTg==
xterm-addon-webgl@^0.11.4:
version "0.11.4"
resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.11.4.tgz#e22f3ec0cafca3d4adcabb89bb7c16efaaf3c8db"
integrity sha512-/a/VFeftc+etGXQYWaaks977j1P7/wickBXn15zDxZzXYYMT9RN17ztqyIDVLXg9krtg28+icKK6lvgIYghJ0w==
xterm-for-react@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/xterm-for-react/-/xterm-for-react-1.0.4.tgz#6b35b9b037a0f9d979e7b57bb1d7c6ab7565b380"
integrity sha512-DCkLR9ZXeW907YyyaCTk/3Ol34VRHfCnf3MAPOkj3dUNA85sDqHvTXN8efw4g7bx7gWdJQRsEpGt2tJOXKG3EQ==
dependencies:
prop-types "^15.7.2"
xterm "^4.5.0"
xterm@^4.18.0, xterm@^4.5.0:
version "4.18.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"