diff --git a/api/observability/tracing/ssh/session.go b/api/observability/tracing/ssh/session.go index bacb0b7855cb3..97f7fc4151045 100644 --- a/api/observability/tracing/ssh/session.go +++ b/api/observability/tracing/ssh/session.go @@ -26,6 +26,7 @@ import ( oteltrace "go.opentelemetry.io/otel/trace" "golang.org/x/crypto/ssh" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/observability/tracing" ) @@ -342,3 +343,32 @@ func (s *Session) CombinedOutput(ctx context.Context, cmd string) ([]byte, error output, err := s.Session.CombinedOutput(cmd) return output, trace.Wrap(err) } + +// sendFileTransferDecision will send a "file-transfer-decision@goteleport.com" ssh request +func (s *Session) sendFileTransferDecision(ctx context.Context, requestID string, approved bool) error { + req := &FileTransferDecisionReq{ + RequestID: requestID, + Approved: approved, + } + _, err := s.SendRequest(ctx, constants.FileTransferDecision, true, ssh.Marshal(req)) + return trace.Wrap(err) +} + +// ApproveFileTransferRequest sends a "file-transfer-decision@goteleport.com" ssh request +// The ssh request will have the request ID and Approved: true +func (s *Session) ApproveFileTransferRequest(ctx context.Context, requestID string) error { + return trace.Wrap(s.sendFileTransferDecision(ctx, requestID, true)) +} + +// DenyFileTransferRequest sends a "file-transfer-decision@goteleport.com" ssh request +// The ssh request will have the request ID and Approved: false +func (s *Session) DenyFileTransferRequest(ctx context.Context, requestID string) error { + return trace.Wrap(s.sendFileTransferDecision(ctx, requestID, false)) +} + +// RequestFileTransfer sends a "file-transfer-request@goteleport.com" ssh request that will create a new file transfer request +// and notify the parties in an ssh session +func (s *Session) RequestFileTransfer(ctx context.Context, req FileTransferReq) error { + _, err := s.SendRequest(ctx, constants.InitiateFileTransfer, true, ssh.Marshal(req)) + return trace.Wrap(err) +} diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index 0e73470ff7f58..5390b9010084a 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -671,6 +671,13 @@ const ( // WebsocketResize is receiving a resize request. WebsocketResize = "w" + // WebsocketFileTransferRequest is received when a new file transfer has been requested + WebsocketFileTransferRequest = "f" + + // WebsocketFileTransferDecision is received when a response (approve/deny) has been + // made for an existing file transfer request + WebsocketFileTransferDecision = "t" + // WebsocketWebauthnChallenge is sending a webauthn challenge. WebsocketWebauthnChallenge = "n" diff --git a/lib/srv/sess.go b/lib/srv/sess.go index 6eb8f22f9eb92..b4e8f6612f98f 100644 --- a/lib/srv/sess.go +++ b/lib/srv/sess.go @@ -350,6 +350,11 @@ func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, erro s.sessionsMux.Lock() defer s.sessionsMux.Unlock() + // get the requested location from env vars + location, _ := scx.GetEnv(sftp.FileTransferDstPath) + if location == "" { + return false, nil + } // if a sessID and requestID environment variables were not set, return not approved and no error. // This means the file transfer came from a non-moderated session. sessionID will be passed after a // moderated session approval process has completed. @@ -375,6 +380,10 @@ func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, erro return false, trace.NotFound("File transfer request not found") } + if req.location != location { + return false, trace.AccessDenied("requested destination path does not match the current request") + } + if req.requester != scx.Identity.TeleportUser { return false, trace.AccessDenied("Teleport user does not match original requester") } @@ -663,6 +672,7 @@ func newSession(ctx context.Context, id rsession.ID, r *SessionRegistry, scx *Se id: id, registry: r, parties: make(map[rsession.ID]*party), + fileTransferRequests: make(map[string]*fileTransferRequest), participants: make(map[rsession.ID]*party), login: scx.Identity.Login, stopC: make(chan struct{}), diff --git a/lib/srv/sess_test.go b/lib/srv/sess_test.go index 277088add85cf..4ee0c4c1f2c0d 100644 --- a/lib/srv/sess_test.go +++ b/lib/srv/sess_test.go @@ -141,6 +141,7 @@ func TestIsApprovedFileTransfer(t *testing.T) { expectedError string req *fileTransferRequest reqID string + location string }{ { @@ -167,14 +168,28 @@ func TestIsApprovedFileTransfer(t *testing.T) { approvers: make(map[string]*party), }, }, + { + name: "current request location does not match original location", + expectedResult: false, + expectedError: "requested destination path does not match the current request", + reqID: "123", + location: "~/Downloads", + req: &fileTransferRequest{ + requester: "michael", + approvers: make(map[string]*party), + location: "~/badlocation", + }, + }, { name: "approved request", expectedResult: true, expectedError: "", reqID: "123", + location: "~/Downloads", req: &fileTransferRequest{ requester: "teleportUser", approvers: approvers, + location: "~/Downloads", }, }, } @@ -193,6 +208,7 @@ func TestIsApprovedFileTransfer(t *testing.T) { scx := newTestServerContext(t, reg.Srv, accessRoleSet) scx.SetEnv(string(sftp.ModeratedSessionID), sess.ID()) scx.SetEnv(string(sftp.FileTransferRequestID), tt.reqID) + scx.SetEnv(sftp.FileTransferDstPath, tt.location) result, err := reg.isApprovedFileTransfer(scx) if err != nil { require.Equal(t, tt.expectedError, err.Error()) diff --git a/lib/sshutils/sftp/http.go b/lib/sshutils/sftp/http.go index cd7b945a8dc82..11181c727e66c 100644 --- a/lib/sshutils/sftp/http.go +++ b/lib/sshutils/sftp/http.go @@ -36,15 +36,18 @@ import ( type contextKey string const ( + // FileTransferDstPath is the dstPath (location) for the requested file transfer. This would be equal + // to the file to be downloaded, or location for a file to be uploaded. + FileTransferDstPath string = "TELEPORT_FILE_TRANSFER_DST_PATH" // FileTransferRequestID is an optional parameter id of an file transfer request that has gone through // an approval process during a moderated session to allow a file transfer scp command to be executed // used as a value in the file transfer context and env var for exec session - FileTransferRequestID contextKey = "FILE_TRANSFER_REQUEST_ID" + FileTransferRequestID contextKey = "TELEPORT_FILE_TRANSFER_REQUEST_ID" // ModeratedSessionID is an optional parameter sent during SCP requests to specify which moderated session // to check for valid FileTransferRequests // used as a value in the file transfer context and env var for exec session - ModeratedSessionID contextKey = "MODERATED_SESSION_ID" + ModeratedSessionID contextKey = "TELEPORT_MODERATED_SESSION_ID" ) var errDirsNotSupported = trace.BadParameter("directories are not supported when transferring files over HTTP") diff --git a/lib/sshutils/sftp/sftp.go b/lib/sshutils/sftp/sftp.go index 0b089d5c7c316..ca4c88e6d508d 100644 --- a/lib/sshutils/sftp/sftp.go +++ b/lib/sshutils/sftp/sftp.go @@ -245,6 +245,8 @@ func (c *Config) TransferFiles(ctx context.Context, sshClient *ssh.Client) error if fileTransferRequestID, ok := ctx.Value(FileTransferRequestID).(string); ok { s.Setenv(string(FileTransferRequestID), fileTransferRequestID) } + // set dstPath in env var to check against file transfer request location + s.Setenv(FileTransferDstPath, c.dstPath) pe, err := s.StderrPipe() if err != nil { diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 7ff4e28a2a33b..8bda547d3aaa7 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -1129,6 +1129,129 @@ func TestResolveServerHostPort(t *testing.T) { } } +func isFileTransferRequest(e *Envelope) bool { + if e.GetType() != defaults.WebsocketAudit { + return false + } + var ef events.EventFields + if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil { + return false + } + return ef.GetType() == string(srv.FileTransferUpdate) +} + +func isFileTransferDecision(e *Envelope) bool { + if e.GetType() != defaults.WebsocketAudit { + return false + } + var ef events.EventFields + if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil { + return false + } + return ef.GetType() == string(srv.FileTransferApproved) +} + +func getRequestId(e *Envelope) (string, error) { + var ef events.EventFields + if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil { + return "", err + } + return ef.GetString("requestID"), nil +} + +func TestFileTransferEvents(t *testing.T) { + t.Parallel() + s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true}) + + errs := make(chan error, 2) + readLoop := func(ctx context.Context, ws *websocket.Conn, ch chan<- *Envelope) { + for { + select { + case <-ctx.Done(): + return + default: + } + + typ, b, err := ws.ReadMessage() + if err != nil { + errs <- err + return + } + if typ != websocket.BinaryMessage { + errs <- trace.BadParameter("expected binary message, got %v", typ) + return + } + var envelope Envelope + if err := proto.Unmarshal(b, &envelope); err != nil { + errs <- trace.Wrap(err) + return + } + ch <- &envelope + } + } + + // Create a new user "foo", open a terminal to a new session + pack := s.authPack(t, "foo") + ws, _, err := s.makeTerminal(t, pack) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, ws.Close()) }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + wsMessages := make(chan *Envelope) + go readLoop(ctx, ws, wsMessages) + + // Create file transfer event + data, err := json.Marshal(events.EventFields{ + "download": true, + "location": "~/myfile.txt", + }) + + require.NoError(t, err) + envelope := &Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketFileTransferRequest, + Payload: string(data), + } + envelopeBytes, err := proto.Marshal(envelope) + require.NoError(t, err) + err = ws.WriteMessage(websocket.BinaryMessage, envelopeBytes) + require.NoError(t, err) + + done := time.After(5 * time.Second) + for { + select { + case <-done: + require.FailNow(t, "expected to receive a file transfer event") + case err := <-errs: + require.NoError(t, err) + case e := <-wsMessages: + if isFileTransferRequest(e) { + requestId, err := getRequestId(e) + require.NoError(t, err) + data, err := json.Marshal(events.EventFields{ + "requestId": requestId, + "approved": true, + }) + require.NoError(t, err) + envelope := &Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketFileTransferDecision, + Payload: string(data), + } + envelopeBytes, err := proto.Marshal(envelope) + require.NoError(t, err) + err = ws.WriteMessage(websocket.BinaryMessage, envelopeBytes) + require.NoError(t, err) + } + + if isFileTransferDecision(e) { + return + } + } + } +} + func TestNewTerminalHandler(t *testing.T) { ctx := context.Background() diff --git a/lib/web/files.go b/lib/web/files.go index d75d949d52921..1c5e455690067 100644 --- a/lib/web/files.go +++ b/lib/web/files.go @@ -70,13 +70,13 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou filename: query.Get("filename"), namespace: defaults.Namespace, webauthn: query.Get("webauthn"), - fileTransferRequestID: query.Get("file_transfer_request_id"), - moderatedSessionID: query.Get("moderated_session_id"), + fileTransferRequestID: query.Get("fileTransferRequestId"), + moderatedSessionID: query.Get("moderatedSessionId"), } // Send an error if only one of these params has been sent. Both should exist or not exist together if (req.fileTransferRequestID != "") != (req.moderatedSessionID != "") { - return nil, trace.BadParameter("file_transfer_request_id and moderated_session_id must both be included in the same request.") + return nil, trace.BadParameter("fileTransferRequestId and moderatedSessionId must both be included in the same request.") } clt, err := sctx.GetUserClient(r.Context(), site) diff --git a/lib/web/terminal.go b/lib/web/terminal.go index ba84b0eb4bc75..34e1daefa27f1 100644 --- a/lib/web/terminal.go +++ b/lib/web/terminal.go @@ -405,7 +405,9 @@ func (t *TerminalHandler) handler(ws *websocket.Conn, r *http.Request) { // Create a terminal stream that wraps/unwraps the envelope used to // communicate over the websocket. resizeC := make(chan *session.TerminalParams, 1) - stream, err := NewTerminalStream(ws, WithTerminalStreamResizeHandler(resizeC)) + fileTransferRequestC := make(chan *session.FileTransferRequestParams, 1) + fileTransferDecisionC := make(chan *session.FileTransferDecisionParams, 1) + stream, err := NewTerminalStream(ws, WithTerminalStreamResizeHandler(resizeC), WithTerminalStreamFileTransferHandlers(fileTransferRequestC, fileTransferDecisionC)) if err != nil { t.log.WithError(err).Info("Failed creating a terminal stream for session") t.writeError(err) @@ -445,6 +447,9 @@ func (t *TerminalHandler) handler(ws *websocket.Conn, r *http.Request) { // process window resizing go t.handleWindowResize(resizeC) + // process file transfer requests/responses + go t.handleFileTransfer(fileTransferRequestC, fileTransferDecisionC) + // Block until the terminal session is complete. <-t.terminalContext.Done() t.log.Debug("Closing websocket stream") @@ -870,6 +875,30 @@ func (t *TerminalHandler) streamEvents(tc *client.TeleportClient) { } } +// handleFileTransfer receives file transfer requests and responses and forwards them +// to the SSH session +func (t *TerminalHandler) handleFileTransfer(fileTransferRequestC <-chan *session.FileTransferRequestParams, fileTransferDecisionC <-chan *session.FileTransferDecisionParams) { + for { + select { + case <-t.terminalContext.Done(): + return + case transferRequest := <-fileTransferRequestC: + t.sshSession.RequestFileTransfer(t.terminalContext, tracessh.FileTransferReq{ + Download: transferRequest.Download, + Location: transferRequest.Location, + Filename: transferRequest.Filename, + }) + case transferResponse := <-fileTransferDecisionC: + if transferResponse.Approved { + t.sshSession.ApproveFileTransferRequest(t.terminalContext, transferResponse.RequestID) + } else { + t.sshSession.DenyFileTransferRequest(t.terminalContext, transferResponse.RequestID) + } + } + + } +} + // handleWindowResize receives window resize events and forwards // them to the SSH session. func (t *TerminalHandler) handleWindowResize(resizeC <-chan *session.TerminalParams) { @@ -970,6 +999,15 @@ func WithTerminalStreamResizeHandler(resizeC chan<- *session.TerminalParams) fun } } +// WithTerminalStreamFileTransferHandlers provides two channels, one to subscribe to new file transfer requests, and +// one to subscribe to file transfer decision requests, such as approve/deny +func WithTerminalStreamFileTransferHandlers(fileTransferRequestC chan<- *session.FileTransferRequestParams, fileTransferDecisionC chan<- *session.FileTransferDecisionParams) func(stream *TerminalStream) { + return func(stream *TerminalStream) { + stream.fileTransferRequestC = fileTransferRequestC + stream.fileTransferDecisionC = fileTransferDecisionC + } +} + // NewTerminalStream creates a stream that manages reading and writing // data over the provided [websocket.Conn] func NewTerminalStream(ws *websocket.Conn, opts ...func(*TerminalStream)) (*TerminalStream, error) { @@ -1008,6 +1046,11 @@ type TerminalStream struct { // resizeC a channel to forward resize events so that // they happen out of band and don't block reads resizeC chan<- *session.TerminalParams + // fileTransferRequestC is a channel to facilitate requesting a file transfer + fileTransferRequestC chan<- *session.FileTransferRequestParams + // fileTransferDecisionC is a channel to facilitate responding to a file transfer + // with an approval or denial + fileTransferDecisionC chan<- *session.FileTransferDecisionParams // mu protects writes to ws mu sync.Mutex @@ -1185,6 +1228,49 @@ func (t *TerminalStream) Read(out []byte) (n int, err error) { default: } + return 0, nil + case defaults.WebsocketFileTransferDecision: + if t.fileTransferDecisionC == nil { + return n, nil + } + var e events.EventFields + err := json.Unmarshal(data, &e) + if err != nil { + return 0, trace.Wrap(err) + } + approved, ok := e["approved"].(bool) + if !ok { + return 0, trace.BadParameter("Unable to find approved status on response") + } + select { + case t.fileTransferDecisionC <- &session.FileTransferDecisionParams{ + RequestID: e.GetString("requestId"), + Approved: approved, + }: + default: + } + return 0, nil + case defaults.WebsocketFileTransferRequest: + if t.fileTransferRequestC == nil { + return n, nil + } + var e events.EventFields + err := json.Unmarshal(data, &e) + if err != nil { + return 0, trace.Wrap(err) + } + download, ok := e["download"].(bool) + if !ok { + return 0, trace.BadParameter("Unable to find download param in response") + } + select { + case t.fileTransferRequestC <- &session.FileTransferRequestParams{ + Location: e.GetString("location"), + Download: download, + Filename: e.GetString("filename"), + }: + default: + } return 0, nil default: return 0, trace.BadParameter("unknown prefix type: %v", envelope.GetType()) @@ -1200,6 +1286,18 @@ func (t *TerminalStream) Close() error { }) } + if t.fileTransferRequestC != nil { + t.once.Do(func() { + close(t.fileTransferRequestC) + }) + } + + if t.fileTransferDecisionC != nil { + t.once.Do(func() { + close(t.fileTransferDecisionC) + }) + } + // Send close envelope to web terminal upon exit without an error. envelope := &Envelope{ Version: defaults.WebsocketVersion, diff --git a/web/packages/shared/components/FileTransfer/FileTransfer.test.tsx b/web/packages/shared/components/FileTransfer/FileTransfer.test.tsx index 60da0278d822e..eb9d2f2c12eb5 100644 --- a/web/packages/shared/components/FileTransfer/FileTransfer.test.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransfer.test.tsx @@ -28,14 +28,27 @@ import { FileTransferContextProvider } from './FileTransferContextProvider'; import { FileTransferDialogDirection } from './FileTransferStateless'; import { createFileTransferEventsEmitter } from './createFileTransferEventsEmitter'; -test('click opens correct dialog', () => { - render( +function FileTransferTestWrapper(props: { + beforeClose?: () => boolean | Promise; + afterClose?: () => void; + transferHandlers: TransferHandlers; +}) { + return ( - + ); +} + +test('click opens correct dialog', () => { + render( + + ); expect(screen.getByText('Download Files')).toBeInTheDocument(); }); @@ -47,11 +60,10 @@ test('downloads component changes when file transfer callbacks are called', asyn getUploader: async () => undefined, }; render( - - - + ); fireEvent.change(screen.getByLabelText('File Path'), { target: { value: '/Users/g/file.txt' }, @@ -81,11 +93,10 @@ test('onAbort is called when user cancels upload', async () => { getUploader: async () => undefined, }; render( - - - + ); fireEvent.change(screen.getByLabelText('File Path'), { target: { value: '/Users/g/file.txt' }, @@ -103,11 +114,10 @@ test('file is not added when transferHandler does not return anything', async () const filePath = '/Users/g/file.txt'; render( - - - + ); fireEvent.change(screen.getByLabelText('File Path'), { target: { value: filePath }, @@ -126,15 +136,11 @@ describe('handleAfterClose', () => { }; render( - - - + ); fireEvent.change(screen.getByLabelText('File Path'), { diff --git a/web/packages/shared/components/FileTransfer/FileTransfer.tsx b/web/packages/shared/components/FileTransfer/FileTransfer.tsx index 591370a9a300d..510e31d65e92d 100644 --- a/web/packages/shared/components/FileTransfer/FileTransfer.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransfer.tsx @@ -17,19 +17,18 @@ import React from 'react'; import { useFileTransferContext } from './FileTransferContextProvider'; -import { useFilesStore } from './useFilesStore'; import { FileTransferDialogDirection, FileTransferListeners, FileTransferStateless, } from './FileTransferStateless'; +import { FileTransferContainer } from './FileTransferContainer'; interface FileTransferProps { backgroundColor?: string; transferHandlers: TransferHandlers; // errorText is any general error that isn't related to a specific transfer errorText?: string; - /** * `beforeClose` is called when an attempt to close the dialog was made * and there is a file transfer in progress. @@ -38,6 +37,8 @@ interface FileTransferProps { beforeClose?(): Promise | boolean; afterClose?(): void; + + FileTransferRequestsComponent?: JSX.Element; } /** @@ -77,18 +78,19 @@ export function FileTransfer(props: FileTransferProps) { } } - if (!openedDialog) { - return null; - } - return ( - + + {props.FileTransferRequestsComponent} + {openedDialog && ( + + )} + ); } @@ -101,7 +103,7 @@ export function FileTransferDialog( onCloseDialog(isAnyTransferInProgress: boolean): void; } ) { - const filesStore = useFilesStore(); + const { filesStore } = useFileTransferContext(); function handleAddDownload(sourcePath: string): void { filesStore.start({ diff --git a/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx b/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx new file mode 100644 index 0000000000000..d44938fe4c949 --- /dev/null +++ b/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx @@ -0,0 +1,25 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import styled from 'styled-components'; + +export const FileTransferContainer = styled.div` + position: absolute; + right: 8px; + width: 500px; + top: 8px; + z-index: 10; +`; diff --git a/web/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx b/web/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx index 7f0fda95269aa..099604a2ef179 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx @@ -14,21 +14,24 @@ * limitations under the License. */ -import React, { useContext, useState, FC } from 'react'; +import React, { useContext, useState, FC, createContext } from 'react'; import { FileTransferDialogDirection } from './FileTransferStateless'; +import { FilesStore, useFilesStore } from './useFilesStore'; const FileTransferContext = - React.createContext<{ + createContext<{ openedDialog: FileTransferDialogDirection; openDownloadDialog(): void; openUploadDialog(): void; closeDialog(): void; + filesStore: FilesStore; }>(null); export const FileTransferContextProvider: FC<{ openedDialog?: FileTransferDialogDirection; }> = props => { + const filesStore = useFilesStore(); const [openedDialog, setOpenedDialog] = useState< FileTransferDialogDirection | undefined >(props.openedDialog); @@ -52,6 +55,7 @@ export const FileTransferContextProvider: FC<{ openDownloadDialog, openUploadDialog, closeDialog, + filesStore, }} children={props.children} /> diff --git a/web/packages/shared/components/FileTransfer/FileTransferRequests.tsx b/web/packages/shared/components/FileTransfer/FileTransferRequests.tsx new file mode 100644 index 0000000000000..4d1252253e20c --- /dev/null +++ b/web/packages/shared/components/FileTransfer/FileTransferRequests.tsx @@ -0,0 +1,151 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { ButtonBorder, Box, Flex, Text } from 'design'; +import * as Icons from 'design/Icon'; +import { + FileTransferRequest, + isOwnRequest, +} from 'teleport/Console/DocumentSsh/useFileTransfer'; +import { useConsoleContext } from 'teleport/Console/consoleContextProvider'; + +type FileTransferRequestsProps = { + requests: FileTransferRequest[]; + onApprove: (requestId: string, approved: boolean) => void; + onDeny: (requestId: string, approved: boolean) => void; +}; + +export const FileTransferRequests = ({ + requests, + onApprove, + onDeny, +}: FileTransferRequestsProps) => { + const ctx = useConsoleContext(); + const currentUser = ctx.getStoreUser(); + + if (requests.length > 0) { + return ( + 0}> + + + File Transfer Requests + + + {requests.map(request => + isOwnRequest(request, currentUser.username) ? ( + + ) : ( + + ) + )} + + ); + } + + // don't show dialog if no requests exist + return null; +}; + +type OwnFormProps = { + request: FileTransferRequest; + onCancel: (requestId: string, approved: boolean) => void; +}; + +const OwnForm = ({ request, onCancel }: OwnFormProps) => { + return ( + + + + {getOwnPendingText(request)} + + onCancel(request.requestID, false)}> + + + + + ); +}; + +const getOwnPendingText = (request: FileTransferRequest) => { + if (request.download) { + return `Pending download: ${request.location}`; + } + return `Pending upload: ${request.filename} to ${request.location}`; +}; + +type RequestFormProps = { + request: FileTransferRequest; + onApprove: (requestId: string, approved: boolean) => void; + onDeny: (requestId: string, approved: boolean) => void; +}; + +const ResponseForm = ({ request, onApprove, onDeny }: RequestFormProps) => { + return ( + + + {getPendingText(request)} + + + onApprove(request.requestID, true)}> + + Approve + + onDeny(request.requestID, false)}> + + Deny + + + + ); +}; + +const getPendingText = (request: FileTransferRequest) => { + if (request.download) { + return `${request.requester} wants to download ${request.location}`; + } + return `${request.requester} wants to upload ${request.filename} to ${request.location}`; +}; + +const Container = styled.div` + background: ${props => + props.backgroundColor || props.theme.colors.levels.surface}; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + border-radius: ${props => props.theme.radii[2]}px; + margin-bottom: 8px; + padding: 8px 16px 16px; +`; diff --git a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx index b3206f599c1a2..95944d1c31540 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx @@ -16,6 +16,8 @@ import React from 'react'; +import { FileTransferContainer } from '../FileTransferContainer'; + import { FileTransferStateless, FileTransferStatelessProps, @@ -49,14 +51,16 @@ function GetFileTransfer( props: Pick ) { return ( - undefined} - onAddDownload={() => undefined} - onAddUpload={() => undefined} - onCancel={() => undefined} - /> + + undefined} + onAddDownload={() => undefined} + onAddUpload={() => undefined} + onCancel={() => undefined} + /> + ); } diff --git a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx index 90ba889608eb8..e9ed155f2d128 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx @@ -96,9 +96,4 @@ const Container = styled.div` box-sizing: border-box; border-radius: ${props => props.theme.radii[2]}px; padding: 8px 16px 16px; - position: absolute; - right: 8px; - top: 8px; - width: 500px; - z-index: 10; `; diff --git a/web/packages/shared/components/FileTransfer/index.ts b/web/packages/shared/components/FileTransfer/index.ts index 01be5f8f44e1f..e148beec874df 100644 --- a/web/packages/shared/components/FileTransfer/index.ts +++ b/web/packages/shared/components/FileTransfer/index.ts @@ -15,6 +15,8 @@ */ export { FileTransfer } from './FileTransfer'; +export { FileTransferContainer } from './FileTransferContainer'; +export { FileTransferRequests } from './FileTransferRequests'; export { FileTransferContextProvider, useFileTransferContext, diff --git a/web/packages/shared/components/FileTransfer/useFilesStore.ts b/web/packages/shared/components/FileTransfer/useFilesStore.ts index 4fe618593e3c2..5378c2fb2f0b1 100644 --- a/web/packages/shared/components/FileTransfer/useFilesStore.ts +++ b/web/packages/shared/components/FileTransfer/useFilesStore.ts @@ -98,44 +98,6 @@ export const useFilesStore = () => { const [state, dispatch] = useReducer(reducer, initialState); const abortControllers = useRef(new Map()); - const start = async (options: { - name: string; - runFileTransfer( - abortController: AbortController - ): Promise; - }) => { - const abortController = new AbortController(); - const fileTransfer = await options.runFileTransfer(abortController); - - if (!fileTransfer) { - return; - } - - const id = new Date().getTime() + options.name; - - dispatch({ type: 'add', payload: { id, name: options.name } }); - abortControllers.current.set(id, abortController); - - fileTransfer.onProgress(progress => { - updateTransferState(id, { - type: 'processing', - progress, - }); - }); - fileTransfer.onError(error => { - updateTransferState(id, { - type: 'error', - progress: undefined, - error, - }); - }); - fileTransfer.onComplete(() => { - updateTransferState(id, { - type: 'completed', - }); - }); - }; - const updateTransferState = useCallback( (id: string, transferState: TransferState) => { dispatch({ type: 'updateTransferState', payload: { id, transferState } }); @@ -143,6 +105,47 @@ export const useFilesStore = () => { [] ); + const start = useCallback( + async (options: { + name: string; + runFileTransfer( + abortController: AbortController + ): Promise; + }) => { + const abortController = new AbortController(); + const fileTransfer = await options.runFileTransfer(abortController); + + if (!fileTransfer) { + return; + } + + const id = new Date().getTime() + options.name; + + dispatch({ type: 'add', payload: { id, name: options.name } }); + abortControllers.current.set(id, abortController); + + fileTransfer.onProgress(progress => { + updateTransferState(id, { + type: 'processing', + progress, + }); + }); + fileTransfer.onError(error => { + updateTransferState(id, { + type: 'error', + progress: undefined, + error, + }); + }); + fileTransfer.onComplete(() => { + updateTransferState(id, { + type: 'completed', + }); + }); + }, + [updateTransferState] + ); + const cancel = useCallback((id: string) => { abortControllers.current?.get(id).abort(); }, []); @@ -164,3 +167,5 @@ export const useFilesStore = () => { isAnyTransferInProgress, }; }; + +export type FilesStore = ReturnType; diff --git a/web/packages/shared/hooks/useAttemptNext.ts b/web/packages/shared/hooks/useAttemptNext.ts index 5e3ec01093da3..e93bce6f2c336 100644 --- a/web/packages/shared/hooks/useAttemptNext.ts +++ b/web/packages/shared/hooks/useAttemptNext.ts @@ -26,10 +26,10 @@ export default function useAttemptNext(status = '' as Attempt['status']) { statusText: '', })); - function handleError(err: Error) { + const handleError = useCallback((err: Error) => { logger.error('attempt', err); setAttempt({ status: 'failed', statusText: err.message }); - } + }, []); const run = useCallback((fn: Callback) => { try { diff --git a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx index 0a95b7c665132..2862ee3839add 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx +++ b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx @@ -21,6 +21,7 @@ import { Indicator, Box } from 'design'; import { FileTransferActionBar, FileTransfer, + FileTransferRequests, FileTransferContextProvider, } from 'shared/components/FileTransfer'; @@ -34,21 +35,35 @@ import Document from '../Document'; import Terminal from './Terminal'; import useSshSession from './useSshSession'; -import { getHttpFileTransferHandlers } from './httpFileTransferHandlers'; -import useGetScpUrl from './useGetScpUrl'; +import { useFileTransfer } from './useFileTransfer'; -export default function DocumentSsh({ doc, visible }: PropTypes) { +export default function DocumentSshWrapper(props: PropTypes) { + return ( + + + + ); +} + +function DocumentSsh({ doc, visible }: PropTypes) { const refTerminal = useRef(); - const { tty, status, closeDocument } = useSshSession(doc); + const { tty, status, closeDocument, session } = useSshSession(doc); const webauthn = useWebAuthn(tty); - const { getScpUrl, attempt: getMfaResponseAttempt } = useGetScpUrl( - webauthn.addMfaToScpUrls - ); + const { + getMfaResponseAttempt, + getDownloader, + getUploader, + fileTransferRequests, + } = useFileTransfer(tty, session, doc, webauthn.addMfaToScpUrls); function handleCloseFileTransfer() { refTerminal.current.terminal.term.focus(); } + function handleFileTransferDecision(requestId: string, approve: boolean) { + tty.approveFileTransferRequest(requestId, approve); + } + useEffect(() => { if (refTerminal?.current) { // when switching tabs or closing tabs, focus on visible terminal @@ -58,73 +73,43 @@ export default function DocumentSsh({ doc, visible }: PropTypes) { return ( - - - {status === 'loading' && ( - - - - )} - {webauthn.requested && ( - - )} - {status === 'initialized' && } - - window.confirm('Are you sure you want to cancel file transfers?') - } - errorText={ - getMfaResponseAttempt.status === 'failed' - ? getMfaResponseAttempt.statusText - : null - } - afterClose={handleCloseFileTransfer} - backgroundColor={colors.levels.surface} - transferHandlers={{ - getDownloader: async (location, abortController) => { - const url = await getScpUrl({ - location, - clusterId: doc.clusterId, - serverId: doc.serverId, - login: doc.login, - filename: location, - }); - if (!url) { - // if we return nothing here, the file transfer will not be added to the - // file transfer list. If we add it to the list, the file will continue to - // start the download and return another here. This prevents a second network - // request that we know will fail. - return; - } - return getHttpFileTransferHandlers().download( - url, - abortController - ); - }, - getUploader: async (location, file, abortController) => { - const url = await getScpUrl({ - location, - clusterId: doc.clusterId, - serverId: doc.serverId, - login: doc.login, - filename: file.name, - }); - if (!url) { - return; - } - return getHttpFileTransferHandlers().upload( - url, - file, - abortController - ); - }, - }} + + {status === 'loading' && ( + + + + )} + {webauthn.requested && ( + - + )} + {status === 'initialized' && } + + } + beforeClose={() => + window.confirm('Are you sure you want to cancel file transfers?') + } + errorText={ + getMfaResponseAttempt.status === 'failed' + ? getMfaResponseAttempt.statusText + : null + } + afterClose={handleCloseFileTransfer} + backgroundColor={colors.levels.surface} + transferHandlers={{ + getDownloader, + getUploader, + }} + /> ); } diff --git a/web/packages/teleport/src/Console/DocumentSsh/useFileTransfer.ts b/web/packages/teleport/src/Console/DocumentSsh/useFileTransfer.ts new file mode 100644 index 0000000000000..505c664c8dc3e --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentSsh/useFileTransfer.ts @@ -0,0 +1,265 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { useFileTransferContext } from 'shared/components/FileTransfer'; + +import Tty from 'teleport/lib/term/tty'; +import { EventType } from 'teleport/lib/term/enums'; +import { Session } from 'teleport/services/session'; +import { DocumentSsh } from 'teleport/Console/stores'; + +import { useConsoleContext } from '../consoleContextProvider'; + +import { getHttpFileTransferHandlers } from './httpFileTransferHandlers'; +import useGetScpUrl from './useGetScpUrl'; + +export type FileTransferRequest = { + sid: string; + requestID: string; + requester: string; + approvers: string[]; + location: string; + filename?: string; + download: boolean; +}; + +export const isOwnRequest = ( + request: FileTransferRequest, + currentUser: string +) => { + return request.requester === currentUser; +}; + +export const useFileTransfer = ( + tty: Tty, + session: Session, + currentDoc: DocumentSsh, + addMfaToScpUrls: boolean +) => { + const { filesStore } = useFileTransferContext(); + const startTransfer = filesStore.start; + const ctx = useConsoleContext(); + const currentUser = ctx.getStoreUser(); + const [fileTransferRequests, setFileTransferRequests] = useState< + FileTransferRequest[] + >([]); + const { getScpUrl, attempt: getMfaResponseAttempt } = + useGetScpUrl(addMfaToScpUrls); + const { clusterId, serverId, login } = currentDoc; + + const download = useCallback( + async ( + location: string, + abortController: AbortController, + moderatedSessionParams?: ModeratedSessionParams + ) => { + const url = await getScpUrl({ + location, + clusterId, + serverId, + login, + filename: location, + moderatedSessionId: moderatedSessionParams?.moderatedSessionId, + fileTransferRequestId: moderatedSessionParams?.fileRequestId, + }); + if (!url) { + // if we return nothing here, the file transfer will not be added to the + // file transfer list. If we add it to the list, the file will continue to + // start the download and return another here. This prevents a second network + // request that we know will fail. + return; + } + return getHttpFileTransferHandlers().download(url, abortController); + }, + [clusterId, login, serverId, getScpUrl] + ); + + const upload = useCallback( + async ( + location: string, + file: File, + abortController: AbortController, + moderatedSessionParams?: ModeratedSessionParams + ) => { + const url = await getScpUrl({ + location, + clusterId, + serverId, + login, + filename: file.name, + moderatedSessionId: moderatedSessionParams?.moderatedSessionId, + fileTransferRequestId: moderatedSessionParams?.fileRequestId, + }); + if (!url) { + // if we return nothing here, the file transfer will not be added to the + // file transfer list. If we add it to the list, the file will continue to + // start the download and return another here. This prevents a second network + // request that we know will fail. + return; + } + return getHttpFileTransferHandlers().upload(url, file, abortController); + }, + [clusterId, serverId, login, getScpUrl] + ); + + /* + * TTY event listeners + */ + + // handleFileTransferDenied is called when a FILE_TRANSFER_REQUEST_DENY event is received + // from the tty. + const handleFileTransferDenied = useCallback( + (request: FileTransferRequest) => { + removeFileTransferRequest(request.requestID); + }, + [] + ); + + // handleFileTransferApproval is called when a FILE_TRANSFER_REQUEST_APPROVE event is received. + // This isn't called when a single approval is received, but rather when the request approval policy has been + // completely fulfilled, i.e. "This request requires two moderators approval and we received both". Any approve that + // doesn't fulfill the policy will be sent as an update and handled in handleFileTransferUpdate + const handleFileTransferApproval = useCallback( + (request: FileTransferRequest, file?: File) => { + removeFileTransferRequest(request.requestID); + if (!isOwnRequest(request, currentUser.username)) { + return; + } + + if (request.download) { + return startTransfer({ + name: request.location, + runFileTransfer: abortController => + download(request.location, abortController, { + fileRequestId: request.requestID, + moderatedSessionId: request.sid, + }), + }); + } + + // if it gets here, it's an upload + if (!file) { + throw new Error('Approved file not found for upload.'); + } + return startTransfer({ + name: request.filename, + runFileTransfer: abortController => + upload(request.location, file, abortController, { + fileRequestId: request.requestID, + moderatedSessionId: request.sid, + }), + }); + }, + [currentUser.username, download, startTransfer, upload] + ); + + // handleFileTransferUpdate is called when a FILE_TRANSFER_REQUEST event is received. This is used when + // we receive a new file transfer request, or when a request has been updated with an approval but its policy isn't + // completely approved yet. An update in this way generally means that the approver array is updated. + function handleFileTransferUpdate(data: FileTransferRequest) { + setFileTransferRequests(prevstate => { + // We receive the same data type when a file transfer request is created and + // when an update event happens. Check if we already have this request in our list. If not + // in our list, we add it + const foundRequest = prevstate.find( + ft => ft.requestID === data.requestID + ); + if (!foundRequest) { + return [...prevstate, data]; + } else { + return prevstate.map(ft => { + if (ft.requestID === data.requestID) { + return data; + } + return ft; + }); + } + }); + } + + useEffect(() => { + // the tty will be init outside of this hook, so we wait until + // it exists and then attach file transfer handlers to it + if (!tty) { + return; + } + tty.on(EventType.FILE_TRANSFER_REQUEST, handleFileTransferUpdate); + tty.on(EventType.FILE_TRANSFER_REQUEST_APPROVE, handleFileTransferApproval); + tty.on(EventType.FILE_TRANSFER_REQUEST_DENY, handleFileTransferDenied); + return () => { + tty.removeListener( + EventType.FILE_TRANSFER_REQUEST, + handleFileTransferUpdate + ); + tty.removeListener( + EventType.FILE_TRANSFER_REQUEST_APPROVE, + handleFileTransferApproval + ); + tty.removeListener( + EventType.FILE_TRANSFER_REQUEST_DENY, + handleFileTransferDenied + ); + }; + }, [tty, handleFileTransferDenied, handleFileTransferApproval]); + + function removeFileTransferRequest(requestId: string) { + setFileTransferRequests(prevstate => + prevstate.filter(ft => ft.requestID !== requestId) + ); + } + + /* + * Transfer handlers + */ + + async function getDownloader( + location: string, + abortController: AbortController + ) { + if (session.moderated) { + tty.sendFileDownloadRequest(location); + return; + } + + return download(location, abortController); + } + + async function getUploader( + location: string, + file: File, + abortController: AbortController + ) { + if (session.moderated) { + tty.sendFileUploadRequest(location, file); + return; + } + + return upload(location, file, abortController); + } + + return { + fileTransferRequests, + getMfaResponseAttempt, + getUploader, + getDownloader, + }; +}; + +type ModeratedSessionParams = { + fileRequestId: string; + moderatedSessionId: string; +}; diff --git a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts index 9d329e2d87a45..d75b3986a76b3 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { useCallback } from 'react'; import useAttempt from 'shared/hooks/useAttemptNext'; import cfg, { UrlScpParams } from 'teleport/config'; @@ -22,28 +23,31 @@ import auth from 'teleport/services/auth/auth'; export default function useGetScpUrl(addMfaToScpUrls: boolean) { const { setAttempt, attempt, handleError } = useAttempt(''); - async function getScpUrl(params: UrlScpParams) { - setAttempt({ - status: 'processing', - statusText: '', - }); - if (!addMfaToScpUrls) { - return cfg.getScpUrl(params); - } - try { - let webauthn = await auth.getWebauthnResponse(); + const getScpUrl = useCallback( + async (params: UrlScpParams) => { setAttempt({ - status: 'success', + status: 'processing', statusText: '', }); - return cfg.getScpUrl({ - webauthn, - ...params, - }); - } catch (error) { - handleError(error); - } - } + if (!addMfaToScpUrls) { + return cfg.getScpUrl(params); + } + try { + let webauthn = await auth.getWebauthnResponse(); + setAttempt({ + status: 'success', + statusText: '', + }); + return cfg.getScpUrl({ + webauthn, + ...params, + }); + } catch (error) { + handleError(error); + } + }, + [addMfaToScpUrls, handleError, setAttempt] + ); return { getScpUrl, diff --git a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts index 1f362c3f93a3f..0eef078af26ad 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts @@ -46,7 +46,6 @@ export default function useSshSession(doc: DocumentSsh) { } React.useEffect(() => { - // initializes tty instances function initTty(session, mode?: ParticipantMode) { tracer.startActiveSpan( 'initTTY', @@ -66,6 +65,7 @@ export default function useSshSession(doc: DocumentSsh) { const data = JSON.parse(payload); data.session.kind = 'ssh'; data.session.resourceName = data.session.server_hostname; + setSession(data.session); handleTtyConnect(ctx, data.session, doc.id); }); @@ -77,12 +77,6 @@ export default function useSshSession(doc: DocumentSsh) { } ); } - - // cleanup by unsubscribing from tty - function cleanup() { - ttyRef.current && ttyRef.current.removeAllListeners(); - } - initTty( { login, @@ -93,10 +87,11 @@ export default function useSshSession(doc: DocumentSsh) { mode ); - return cleanup; + function teardownTty() { + ttyRef.current && ttyRef.current.removeAllListeners(); + } - // Only run this once on the initial render. - // eslint-disable-next-line react-hooks/exhaustive-deps + return teardownTty; }, []); return { diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 82ca54d2d76c3..11c88a14a61f0 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -136,7 +136,7 @@ const cfg = { connectionDiagnostic: `/v1/webapi/sites/:clusterId/diagnostics/connections`, checkAccessToRegisteredResource: `/v1/webapi/sites/:clusterId/resources/check`, - scp: '/v1/webapi/sites/:clusterId/nodes/:serverId/:login/scp?location=:location&filename=:filename', + scp: '/v1/webapi/sites/:clusterId/nodes/:serverId/:login/scp?location=:location&filename=:filename&moderatedSessionId=:moderatedSessionId?&fileTransferRequestId=:fileTransferRequestId?', webRenewTokenPath: '/v1/webapi/sessions/web/renew', resetPasswordTokenPath: '/v1/webapi/users/password/token', webSessionPath: '/v1/webapi/sessions/web', @@ -572,6 +572,7 @@ const cfg = { let path = generatePath(cfg.api.scp, { ...params, }); + if (!webauthn) { return path; } @@ -673,6 +674,8 @@ export interface UrlScpParams { login: string; location: string; filename: string; + moderatedSessionId?: string; + fileTransferRequestId?: string; webauthn?: WebauthnAssertionResponse; } diff --git a/web/packages/teleport/src/lib/term/enums.ts b/web/packages/teleport/src/lib/term/enums.ts index 0cc0e355dd50b..b1cd690816765 100644 --- a/web/packages/teleport/src/lib/term/enums.ts +++ b/web/packages/teleport/src/lib/term/enums.ts @@ -20,6 +20,10 @@ export enum EventType { END = 'session.end', PRINT = 'print', RESIZE = 'resize', + FILE_TRANSFER_REQUEST = 'file_transfer_request', + FILE_TRANSFER_DECISION = 'file_transfer_decision', + FILE_TRANSFER_REQUEST_APPROVE = 'file_transfer_request_approve', + FILE_TRANSFER_REQUEST_DENY = 'file_transfer_request_deny', } export enum TermEvent { diff --git a/web/packages/teleport/src/lib/term/protobuf.js b/web/packages/teleport/src/lib/term/protobuf.js index 754fe96ab5f3c..96f67ef6cd4bd 100644 --- a/web/packages/teleport/src/lib/term/protobuf.js +++ b/web/packages/teleport/src/lib/term/protobuf.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import BufferModule from 'buffer/'; +import { Buffer } from 'buffer/'; /** * convenience constant equal to 2^32. @@ -27,6 +27,8 @@ export const MessageTypeEnum = { SESSION_DATA: 's', SESSION_END: 'c', RESIZE: 'w', + FILE_TRANSFER_REQUEST: 'f', + FILE_TRANSFER_DECISION: 't', WEBAUTHN_CHALLENGE: 'n', }; @@ -48,6 +50,9 @@ export const messageFields = { code: 0x12, values: { resize: MessageTypeEnum.RESIZE.charCodeAt(0), + fileTransferRequest: MessageTypeEnum.FILE_TRANSFER_REQUEST.charCodeAt(0), + fileTransferDecision: + MessageTypeEnum.FILE_TRANSFER_DECISION.charCodeAt(0), data: MessageTypeEnum.RAW.charCodeAt(0), event: MessageTypeEnum.AUDIT.charCodeAt(0), close: MessageTypeEnum.SESSION_END.charCodeAt(0), @@ -68,6 +73,14 @@ export class Protobuf { return this.encode(messageFields.type.values.resize, message); } + encodeFileTransferRequest(message) { + return this.encode(messageFields.type.values.fileTransferRequest, message); + } + + encodeFileTransferDecision(message) { + return this.encode(messageFields.type.values.fileTransferDecision, message); + } + encodeRawMessage(message) { return this.encode(messageFields.type.values.data, message); } @@ -177,7 +190,7 @@ export class Protobuf { } _textToUintArray(text) { - return BufferModule.Buffer(text); + return Buffer(text); } _uintArrayToText(uintArray) { @@ -185,7 +198,7 @@ export class Protobuf { if (window.TextDecoder) { return new TextDecoder('utf-8').decode(uintArray); } else { - return BufferModule.Buffer(uintArray).toString(); + return Buffer(uintArray).toString(); } } } diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 702e56468f621..602cba36932de 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -36,6 +36,7 @@ class Tty extends EventEmitterWebAuthnSender { _attachSocketBuffer: string; _addressResolver = null; _proto = new Protobuf(); + _pendingUploads = {}; constructor(addressResolver, props = {}) { super(); @@ -80,6 +81,44 @@ class Tty extends EventEmitterWebAuthnSender { this.send(JSON.stringify(data)); } + _sendFileTransferRequest(message: string) { + const encoded = this._proto.encodeFileTransferRequest(message); + const bytearray = new Uint8Array(encoded); + this.socket.send(bytearray); + } + + sendFileDownloadRequest(location: string) { + const message = JSON.stringify({ + event: EventType.FILE_TRANSFER_REQUEST, + download: true, + location, + }); + this._sendFileTransferRequest(message); + } + + sendFileUploadRequest(location: string, file: File) { + const locationAndName = location + file.name; + this._pendingUploads[locationAndName] = file; + const message = JSON.stringify({ + event: EventType.FILE_TRANSFER_REQUEST, + download: false, + location, + filename: file.name, + }); + this._sendFileTransferRequest(message); + } + + approveFileTransferRequest(requestId: string, approved: boolean) { + const message = JSON.stringify({ + event: EventType.FILE_TRANSFER_DECISION, + requestId, + approved, + }); + const encoded = this._proto.encodeFileTransferDecision(message); + const bytearray = new Uint8Array(encoded); + this.socket.send(bytearray); + } + // part of the flow control pauseFlow() {} @@ -168,6 +207,35 @@ class Tty extends EventEmitterWebAuthnSender { _processAuditPayload(payload) { const event = JSON.parse(payload); + // received a new/updated file transfer request + if (event.event === EventType.FILE_TRANSFER_REQUEST) { + this.emit(EventType.FILE_TRANSFER_REQUEST, event); + } + + // received a file transfer approval + if (event.event === EventType.FILE_TRANSFER_REQUEST_APPROVE) { + const isDownload = event.download === true; + let pendingFile: File = null; + // if the approval is for an upload, fetch the file pending upload + if (!isDownload) { + const locationAndName = event.location + event.filename; + pendingFile = this._getPendingFile(locationAndName); + // cleanup if file exists. It's ok if it doesn't exist, we check thaat in the handler + if (pendingFile) { + delete this._pendingUploads[locationAndName]; + } + } + this.emit(EventType.FILE_TRANSFER_REQUEST_APPROVE, event, pendingFile); + } + + // received a file transfer denial + if (event.event === EventType.FILE_TRANSFER_REQUEST_DENY) { + const locationAndName = event.location + event.filename; + delete this._pendingUploads[locationAndName]; + this.emit(EventType.FILE_TRANSFER_REQUEST_DENY, event); + } + + // received a window resize if (event.event === EventType.RESIZE) { let [w, h] = event.size.split(':'); w = Number(w); @@ -175,6 +243,10 @@ class Tty extends EventEmitterWebAuthnSender { this.emit(TermEvent.RESIZE, { w, h }); } } + + _getPendingFile(location: string) { + return this._pendingUploads[location]; + } } export default Tty;