diff --git a/integration/kube_integration_test.go b/integration/kube_integration_test.go index c474ab53c2eee..af9ce8efd59ae 100644 --- a/integration/kube_integration_test.go +++ b/integration/kube_integration_test.go @@ -77,6 +77,7 @@ import ( "github.com/gravitational/teleport/lib" "github.com/gravitational/teleport/lib/auth/testauthority" "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" kubeutils "github.com/gravitational/teleport/lib/kube/utils" "github.com/gravitational/teleport/lib/modules" @@ -1675,25 +1676,44 @@ func testKubeExecWeb(t *testing.T, suite *KubeSuite) { // Login and run the tests. webPack := helpers.LoginWebClient(t, proxyAddr.String(), testUser, userPassword) - endpoint, err := url.JoinPath("sites", "$site", "kube", kubeClusterName, "connect/ws") // :site/kube/:clusterName/connect/ws - require.NoError(t, err) + endpoint := "sites/$site/kube/exec/ws" openWebsocketAndReadSession := func(t *testing.T, endpoint string, req web.PodExecRequest) *websocket.Conn { - ws, resp, err := webPack.OpenWebsocket(t, endpoint, req) + termSize := struct { + Term session.TerminalParams `json:"term"` + }{ + Term: session.TerminalParams{W: req.Term.W, H: req.Term.H}, + } + ws, resp, err := webPack.OpenWebsocket(t, endpoint, termSize) require.NoError(t, err) require.NoError(t, resp.Body.Close()) - _, data, err := ws.ReadMessage() + data, err := json.Marshal(req) + require.NoError(t, err) + + reqEnvelope := &terminal.Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketKubeExec, + Payload: string(data), + } + + envelopeBytes, err := proto.Marshal(reqEnvelope) + require.NoError(t, err) + + err = ws.WriteMessage(websocket.BinaryMessage, envelopeBytes) + require.NoError(t, err) + + _, data, err = ws.ReadMessage() require.NoError(t, err) require.Equal(t, `{"type":"create_session_response","status":"ok"}`+"\n", string(data)) execSocket := executionWebsocketReader{ws} // First message: session metadata - envelope, err := execSocket.Read() + sessionEnvelope, err := execSocket.Read() require.NoError(t, err) var sessionMetadata sessionMetadataResponse - require.NoError(t, json.Unmarshal([]byte(envelope.Payload), &sessionMetadata)) + require.NoError(t, json.Unmarshal([]byte(sessionEnvelope.Payload), &sessionMetadata)) return ws } diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index 60855355006e3..d676b08f680bf 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -723,6 +723,9 @@ const ( // WebsocketLatency provides latency information for a session. WebsocketLatency = "l" + + // WebsocketKubeExec provides latency information for a session. + WebsocketKubeExec = "k" ) // The following are cryptographic primitives Teleport does not support in diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 9358a59e19b38..968c50383734b 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -40,6 +40,7 @@ import ( "sync/atomic" "time" + gogoproto "github.com/gogo/protobuf/proto" "github.com/google/safetext/shsprintf" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -774,7 +775,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/connect/ws", h.WithClusterAuthWebSocket(h.siteNodeConnect)) // connect to an active session (via websocket, with auth over websocket) h.GET("/webapi/sites/:site/sessions", h.WithClusterAuth(h.clusterActiveAndPendingSessionsGet)) // get list of active and pending sessions - h.GET("/webapi/sites/:site/kube/:clusterName/connect/ws", h.WithClusterAuthWebSocket(h.podConnect)) // connect to a pod with exec (via websocket, with auth over websocket) + h.GET("/webapi/sites/:site/kube/exec/ws", h.WithClusterAuthWebSocket(h.podConnect)) // connect to a pod with exec (via websocket, with auth over websocket) // Audit events handlers. h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events @@ -3370,6 +3371,11 @@ func (h *Handler) siteNodeConnect( return nil, nil } +type podConnectParams struct { + // Term is the initial PTY size. + Term session.TerminalParams `json:"term"` +} + func (h *Handler) podConnect( w http.ResponseWriter, r *http.Request, @@ -3379,13 +3385,29 @@ func (h *Handler) podConnect( ws *websocket.Conn, ) (interface{}, error) { q := r.URL.Query() - params := q.Get("params") - if params == "" { + if q.Get("params") == "" { return nil, trace.BadParameter("missing params") } + var params podConnectParams + if err := json.Unmarshal([]byte(q.Get("params")), ¶ms); err != nil { + return nil, trace.Wrap(err) + } - var execReq PodExecRequest - if err := json.Unmarshal([]byte(params), &execReq); err != nil { + execReq, err := readPodExecRequestFromWS(ws) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || terminal.IsOKWebsocketCloseError(trace.Unwrap(err)) { + return nil, nil + } + var netError net.Error + if errors.As(trace.Unwrap(err), &netError) && netError.Timeout() { + return nil, trace.BadParameter("timed out waiting for pod exec request data on websocket connection") + } + + return nil, trace.Wrap(err) + } + execReq.Term = params.Term + + if err := execReq.Validate(); err != nil { return nil, trace.Wrap(err) } @@ -3405,7 +3427,7 @@ func (h *Handler) podConnect( sess := session.Session{ Kind: types.KubernetesSessionKind, - Login: sctx.GetUser(), + Login: "root", ClusterName: clusterName, KubernetesClusterName: execReq.KubeCluster, Moderated: accessEvaluator.IsModerated(), @@ -3414,6 +3436,7 @@ func (h *Handler) podConnect( LastActive: h.clock.Now().UTC(), Namespace: apidefaults.Namespace, Owner: sctx.GetUser(), + Command: execReq.Command, } h.log.Debugf("New kube exec request for namespace=%s pod=%s container=%s, sid=%s, websid=%s.", @@ -3462,6 +3485,42 @@ func (h *Handler) podConnect( return nil, nil } +// KubeExecDataWaitTimeout is how long server would wait for user to send pod exec data (namespace, pod name etc) +// on websocket connection, after user initiated the exec into pod flow. +const KubeExecDataWaitTimeout = defaults.HeadlessLoginTimeout + +func readPodExecRequestFromWS(ws *websocket.Conn) (*PodExecRequest, error) { + err := ws.SetReadDeadline(time.Now().Add(KubeExecDataWaitTimeout)) + if err != nil { + return nil, trace.Wrap(err, "failed to set read deadline for websocket connection") + } + + messageType, bytes, err := ws.ReadMessage() + if err != nil { + return nil, trace.Wrap(err) + } + + if err := ws.SetReadDeadline(time.Time{}); err != nil { + return nil, trace.Wrap(err, "failed to set read deadline for websocket connection") + } + + if messageType != websocket.BinaryMessage { + return nil, trace.BadParameter("Expected binary message of type websocket.BinaryMessage, got %v", messageType) + } + + var envelope terminal.Envelope + if err := gogoproto.Unmarshal(bytes, &envelope); err != nil { + return nil, trace.BadParameter("Failed to parse envelope: %v", err) + } + + var req PodExecRequest + if err := json.Unmarshal([]byte(envelope.Payload), &req); err != nil { + return nil, trace.Wrap(err) + } + + return &req, nil +} + func (h *Handler) getKubeExecClusterData(netConfig types.ClusterNetworkingConfig) (string, string, error) { if netConfig.GetProxyListenerMode() == types.ProxyListenerMode_Separate { return "https://" + h.kubeProxyHostPort(), "", nil diff --git a/lib/web/kube.go b/lib/web/kube.go index e16276d7472be..f05e52d011b48 100644 --- a/lib/web/kube.go +++ b/lib/web/kube.go @@ -58,7 +58,7 @@ type podHandler struct { teleportCluster string configTLSServerName string configServerAddr string - req PodExecRequest + req *PodExecRequest sess session.Session sctx *SessionContext ws *websocket.Conn @@ -75,6 +75,8 @@ type podHandler struct { // PodExecRequest describes a request to create a web-based terminal // to exec into a pod. type PodExecRequest struct { + // KubeCluster specifies what Kubernetes cluster to connect to. + KubeCluster string `json:"kubeCluster"` // Namespace is the namespace of the target pod Namespace string `json:"namespace"` // Pod is the target pod to connect to. @@ -83,14 +85,41 @@ type PodExecRequest struct { Container string `json:"container"` // Command is the command to run at the target pod. Command string `json:"command"` - // KubeCluster specifies what Kubernetes cluster to connect to. - KubeCluster string `json:"kube_cluster"` // IsInteractive specifies whether exec request should have interactive TTY. - IsInteractive bool `json:"is_interactive"` + IsInteractive bool `json:"isInteractive"` // Term is the initial PTY size. Term session.TerminalParams `json:"term"` } +func (r *PodExecRequest) Validate() error { + if r.KubeCluster == "" { + return trace.BadParameter("missing parameter KubeCluster") + } + if r.Namespace == "" { + return trace.BadParameter("missing parameter Namespace") + } + if r.Pod == "" { + return trace.BadParameter("missing parameter Pod") + } + if r.Command == "" { + return trace.BadParameter("missing parameter Command") + } + if len(r.Namespace) > 63 { + return trace.BadParameter("Namespace is too long, maximum length is 63 characters") + } + if len(r.Pod) > 63 { + return trace.BadParameter("Pod is too long, maximum length is 63 characters") + } + if len(r.Container) > 63 { + return trace.BadParameter("Container is too long, maximum length is 63 characters") + } + if len(r.Command) > 10000 { + return trace.BadParameter("Command is too long, maximum length is 10000 characters") + } + + return nil +} + // ServeHTTP sends session metadata to web UI to signal beginning of the session, then // handles Kube exec request and connects it to web based terminal input/output. func (p *podHandler) ServeHTTP(_ http.ResponseWriter, r *http.Request) { @@ -192,7 +221,11 @@ func (p *podHandler) handler(r *http.Request) error { TLSCert: p.sctx.cfg.Session.GetTLSCert(), } - stream := terminal.NewStream(ctx, terminal.StreamConfig{WS: p.ws, Logger: p.log}) + resizeQueue := newTermSizeQueue(ctx, remotecommand.TerminalSize{ + Width: p.req.Term.Winsize().Width, + Height: p.req.Term.Winsize().Height, + }) + stream := terminal.NewStream(ctx, terminal.StreamConfig{WS: p.ws, Logger: p.log, Handlers: map[string]terminal.WSHandlerFunc{defaults.WebsocketResize: p.handleResize(resizeQueue)}}) certsReq := clientproto.UserCertsRequest{ PublicKey: userKey.MarshalSSHPublicKey(), @@ -267,13 +300,15 @@ func (p *podHandler) handler(r *http.Request) error { } streamOpts := remotecommand.StreamOptions{ - Stdin: stream, - Stdout: stream, - Tty: p.req.IsInteractive, + Stdin: stream, + Stdout: stream, + Tty: p.req.IsInteractive, + TerminalSizeQueue: resizeQueue, } if !p.req.IsInteractive { streamOpts.Stderr = stderrWriter{stream: stream} } + if err := wsExec.StreamWithContext(ctx, streamOpts); err != nil { return trace.Wrap(err, "failed exec command streaming") } @@ -302,6 +337,68 @@ func (p *podHandler) handler(r *http.Request) error { return nil } +func (p *podHandler) handleResize(termSizeQueue *termSizeQueue) func(context.Context, terminal.Envelope) { + return func(ctx context.Context, envelope terminal.Envelope) { + var e map[string]any + if err := json.Unmarshal([]byte(envelope.Payload), &e); err != nil { + p.log.Warnf("Failed to parse resize payload: %v", err) + return + } + + size, ok := e["size"].(string) + if !ok { + p.log.Errorf("expected size to be of type string, got type %T instead", size) + return + } + + params, err := session.UnmarshalTerminalParams(size) + if err != nil { + p.log.Warnf("Failed to retrieve terminal size: %v", err) + return + } + + // nil params indicates the channel was closed + if params == nil { + return + } + + termSizeQueue.AddSize(remotecommand.TerminalSize{ + Width: params.Winsize().Width, + Height: params.Winsize().Height, + }) + } +} + +type termSizeQueue struct { + incoming chan remotecommand.TerminalSize + ctx context.Context +} + +func newTermSizeQueue(ctx context.Context, initialSize remotecommand.TerminalSize) *termSizeQueue { + queue := &termSizeQueue{ + incoming: make(chan remotecommand.TerminalSize, 1), + ctx: ctx, + } + queue.AddSize(initialSize) + return queue +} + +func (r *termSizeQueue) Next() *remotecommand.TerminalSize { + select { + case <-r.ctx.Done(): + return nil + case size := <-r.incoming: + return &size + } +} + +func (r *termSizeQueue) AddSize(term remotecommand.TerminalSize) { + select { + case <-r.ctx.Done(): + case r.incoming <- term: + } +} + func createKubeRestConfig(serverAddr, tlsServerName string, ca types.CertAuthority, clientCert, rsaKey []byte) (*rest.Config, error) { var clusterCACerts [][]byte for _, keyPair := range ca.GetTrustedTLSKeyPairs() { diff --git a/lib/web/terminal/terminal.go b/lib/web/terminal/terminal.go index c92dae03fb2d1..58c7a732513e9 100644 --- a/lib/web/terminal/terminal.go +++ b/lib/web/terminal/terminal.go @@ -124,7 +124,7 @@ func (t *WSStream) SetReadDeadline(deadline time.Time) error { return t.WSConn.SetReadDeadline(deadline) } -func isOKWebsocketCloseError(err error) bool { +func IsOKWebsocketCloseError(err error) bool { return websocket.IsCloseError(err, websocket.CloseAbnormalClosure, websocket.CloseGoingAway, @@ -145,7 +145,7 @@ func (t *WSStream) processMessages(ctx context.Context) { default: ty, bytes, err := t.WSConn.ReadMessage() if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || isOKWebsocketCloseError(err) { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || IsOKWebsocketCloseError(err) { return } diff --git a/web/packages/build/vite/config.ts b/web/packages/build/vite/config.ts index 75ddc94a57e35..d9a54053fd443 100644 --- a/web/packages/build/vite/config.ts +++ b/web/packages/build/vite/config.ts @@ -136,6 +136,13 @@ export function createViteConfig( secure: false, ws: true, }, + // /webapi/sites/:site/kube/exec + [`^\\/v1\\/webapi\\/sites\\/${siteName}\\/kube/exec`]: { + target: `wss://${target}`, + changeOrigin: false, + secure: false, + ws: true, + }, // /webapi/sites/:site/desktopplayback/:sid '^\\/v1\\/webapi\\/sites\\/(.*?)\\/desktopplayback\\/(.*?)': { target: `wss://${target}`, diff --git a/web/packages/teleport/src/Console/Console.tsx b/web/packages/teleport/src/Console/Console.tsx index 4384bd93a5da3..a5464052fd7d7 100644 --- a/web/packages/teleport/src/Console/Console.tsx +++ b/web/packages/teleport/src/Console/Console.tsx @@ -31,6 +31,7 @@ import Tabs from './Tabs'; import ActionBar from './ActionBar'; import DocumentSsh from './DocumentSsh'; import DocumentNodes from './DocumentNodes'; +import DocumentKubeExec from './DocumentKubeExec'; import DocumentBlank from './DocumentBlank'; import usePageTitle from './usePageTitle'; import useTabRouting from './useTabRouting'; @@ -141,6 +142,8 @@ function MemoizedDocument(props: { doc: stores.Document; visible: boolean }) { return ; case 'nodes': return ; + case 'kubeExec': + return ; default: return ; } diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.story.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.story.tsx new file mode 100644 index 0000000000000..e511149dd3b53 --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.story.tsx @@ -0,0 +1,136 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import ConsoleCtx from 'teleport/Console/consoleContext'; + +import { ContextProvider } from 'teleport'; +import { TestLayout } from 'teleport/Console/Console.story'; +import TeleportContext from 'teleport/teleportContext'; +import * as stores from 'teleport/Console/stores/types'; + +import DocumentKubeExec from './DocumentKubeExec'; + +import type { Session } from 'teleport/services/session'; + +export default { + title: 'Teleport/Console/DocumentKubeExec', +}; + +export const Connected = () => { + const { ctx, consoleCtx } = getContexts(); + + return ( + + ); +}; + +export const NotFound = () => { + const { ctx, consoleCtx } = getContexts(); + + const disconnectedDoc = { + ...baseDoc, + status: 'disconnected' as const, + }; + + return ( + + ); +}; + +export const ServerError = () => { + const { ctx, consoleCtx } = getContexts(); + + const noSidDoc = { + ...baseDoc, + sid: '', + }; + + return ( + + ); +}; + +type Props = { + ctx: TeleportContext; + consoleCtx: ConsoleCtx; + doc: stores.DocumentKubeExec; +}; + +const DocumentKubeExecWrapper = ({ ctx, consoleCtx, doc }: Props) => { + return ( + + + + + + ); +}; + +function getContexts() { + const ctx = createTeleportContext(); + const consoleCtx = new ConsoleCtx(); + const tty = consoleCtx.createTty(session); + tty.connect = () => null; + consoleCtx.createTty = () => tty; + consoleCtx.storeUser = ctx.storeUser; + + return { ctx, consoleCtx }; +} + +const baseDoc = { + kind: 'kubeExec', + status: 'connected', + sid: 'sid-value', + clusterId: 'clusterId-value', + serverId: 'serverId-value', + login: 'login-value', + kubeCluster: 'kubeCluster1', + kubeNamespace: 'namespace1', + pod: 'pod1', + container: '', + id: 3, + url: 'fd', + created: new Date(), + command: '/bin/bash', + isInteractive: true, +} as const; + +const session: Session = { + kind: 'k8s', + login: '123', + sid: '456', + namespace: '', + created: new Date(), + durationText: '', + serverId: '', + resourceName: '', + clusterId: '', + parties: [], + addr: '', + participantModes: [], + moderated: false, + isInteractive: true, + command: '/bin/bash', +}; diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.test.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.test.tsx new file mode 100644 index 0000000000000..7405a7c23f082 --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.test.tsx @@ -0,0 +1,164 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import '@testing-library/jest-dom'; + +import { ThemeProvider } from 'styled-components'; +import 'jest-canvas-mock'; +import { darkTheme } from 'design/theme'; + +import { act } from 'design/utils/testing'; + +import { ContextProvider } from 'teleport'; +import { TestLayout } from 'teleport/Console/Console.story'; +import ConsoleCtx from 'teleport/Console/consoleContext'; +import { createTeleportContext } from 'teleport/mocks/contexts'; + +import Tty from 'teleport/lib/term/tty'; + +import useKubeExecSession, { Status } from './useKubeExecSession'; + +import DocumentKubeExec from './DocumentKubeExec'; + +import type { Session } from 'teleport/services/session'; + +jest.mock('./useKubeExecSession'); + +const mockUseKubeExecSession = useKubeExecSession as jest.MockedFunction< + typeof useKubeExecSession +>; + +describe('DocumentKubeExec', () => { + const setup = (status: Status) => { + mockUseKubeExecSession.mockReturnValue({ + tty: { + sendKubeExecData: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + removeAllListeners: jest.fn(), + } as unknown as Tty, + status, + closeDocument: jest.fn(), + sendKubeExecData: jest.fn(), + session: baseSession, + }); + + const { ctx, consoleCtx } = getContexts(); + + render( + + + + + + + + ); + }; + + 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(), + })), + }); + + test('renders loading indicator when status is loading', async () => { + jest.useFakeTimers(); + setup('loading'); + + act(() => jest.runAllTimers()); + expect(screen.getByTestId('indicator')).toBeInTheDocument(); + }); + + test('renders terminal window when status is initialized', () => { + setup('initialized'); + + expect(screen.getByTestId('terminal')).toBeInTheDocument(); + }); + + test('renders data dialog when status is waiting-for-exec-data', () => { + setup('waiting-for-exec-data'); + + expect(screen.getByText('Exec into a pod')).toBeInTheDocument(); + }); + + test('does not render data dialog when status is initialized', () => { + setup('initialized'); + + expect(screen.queryByText('Exec into a pod')).not.toBeInTheDocument(); + }); +}); + +function getContexts() { + const ctx = createTeleportContext(); + const consoleCtx = new ConsoleCtx(); + const tty = consoleCtx.createTty(baseSession); + tty.connect = () => null; + consoleCtx.createTty = () => tty; + consoleCtx.storeUser = ctx.storeUser; + + return { ctx, consoleCtx }; +} + +const baseDoc = { + kind: 'kubeExec', + status: 'connected', + sid: 'sid-value', + clusterId: 'clusterId-value', + serverId: 'serverId-value', + login: 'login-value', + kubeCluster: 'kubeCluster1', + kubeNamespace: 'namespace1', + pod: 'pod1', + container: '', + id: 3, + url: 'fd', + created: new Date(), + command: '/bin/bash', + isInteractive: true, +} as const; + +const baseSession: Session = { + kind: 'k8s', + login: '123', + sid: '456', + namespace: '', + created: new Date(), + durationText: '', + serverId: '', + resourceName: '', + clusterId: '', + parties: [], + addr: '', + participantModes: [], + moderated: false, + isInteractive: true, + command: '/bin/bash', +}; diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.tsx new file mode 100644 index 0000000000000..d501afda24b0b --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/DocumentKubeExec.tsx @@ -0,0 +1,80 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import React, { useRef, useEffect } from 'react'; + +import { useTheme } from 'styled-components'; +import { Box, Indicator } from 'design'; + +import * as stores from 'teleport/Console/stores/types'; +import { Terminal, TerminalRef } from 'teleport/Console/DocumentSsh/Terminal'; +import useWebAuthn from 'teleport/lib/useWebAuthn'; +import useKubeExecSession from 'teleport/Console/DocumentKubeExec/useKubeExecSession'; + +import Document from 'teleport/Console/Document'; +import AuthnDialog from 'teleport/components/AuthnDialog'; + +import KubeExecData from './KubeExecDataDialog'; + +type Props = { + visible: boolean; + doc: stores.DocumentKubeExec; +}; + +export default function DocumentKubeExec({ doc, visible }: Props) { + const terminalRef = useRef(); + const { tty, status, closeDocument, sendKubeExecData } = + useKubeExecSession(doc); + const webauthn = useWebAuthn(tty); + useEffect(() => { + // when switching tabs or closing tabs, focus on visible terminal + terminalRef.current?.focus(); + }, [visible, webauthn.requested]); + const theme = useTheme(); + + const terminal = ( + + ); + + return ( + + {status === 'loading' && ( + + + + )} + {webauthn.requested && ( + + )} + + {status === 'waiting-for-exec-data' && ( + + )} + {status !== 'loading' && terminal} + + ); +} diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx new file mode 100644 index 0000000000000..36b8b7a505820 --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx @@ -0,0 +1,171 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState } from 'react'; +import Dialog, { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'design/Dialog'; +import { + Box, + ButtonPrimary, + ButtonSecondary, + Flex, + Text, + Toggle, +} from 'design'; + +import Validation from 'shared/components/Validation'; +import FieldInput from 'shared/components/FieldInput'; +import { requiredField } from 'shared/components/Validation/rules'; +import { ToolTipInfo } from 'shared/components/ToolTip'; + +type Props = { + onClose(): void; + onExec( + namespace: string, + pod: string, + container: string, + command: string, + isInteractive: boolean + ): void; +}; + +function KubeExecDataDialog({ onClose, onExec }: Props) { + const [namespace, setNamespace] = useState(''); + const [pod, setPod] = useState(''); + const [container, setContainer] = useState(''); + const [execCommand, setExecCommand] = useState(''); + const [execInteractive, setExecInteractive] = useState(true); + + const podExec = () => { + onExec(namespace, pod, container, execCommand, execInteractive); + }; + + return ( + + + {({ validator }) => ( + + + Exec into a pod + + + + + setNamespace(e.target.value.trim())} + /> + setPod(e.target.value.trim())} + /> + setContainer(e.target.value.trim())} + /> + + + setExecCommand(e.target.value)} + toolTipContent={ + + The command that will be executed inside the target pod. + + } + /> + { + setExecInteractive(b => !b); + }} + > + + Interactive shell + + + You can start an interactive shell and have a + bidirectional communication with the target pod, or you + can run one-off command and see its output. + + + + + + + + + Close + + { + e.preventDefault(); + validator.validate() && podExec(); + }} + > + Exec + + + + + )} + + + ); +} + +const dialogCss = () => ` + min-height: 200px; + max-width: 600px; + width: 100%; +`; + +export default KubeExecDataDialog; diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/index.ts b/web/packages/teleport/src/Console/DocumentKubeExec/index.ts new file mode 100644 index 0000000000000..14f5557a9dbb6 --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/index.ts @@ -0,0 +1,20 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import DocumentKubeExec from './DocumentKubeExec'; +export default DocumentKubeExec; diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/useKubeExecSession.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/useKubeExecSession.tsx new file mode 100644 index 0000000000000..37621a47c45f2 --- /dev/null +++ b/web/packages/teleport/src/Console/DocumentKubeExec/useKubeExecSession.tsx @@ -0,0 +1,180 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; + +import { context, trace } from '@opentelemetry/api'; + +import cfg from 'teleport/config'; +import { TermEvent } from 'teleport/lib/term/enums'; +import Tty from 'teleport/lib/term/tty'; +import ConsoleContext from 'teleport/Console/consoleContext'; +import { useConsoleContext } from 'teleport/Console/consoleContextProvider'; +import { DocumentKubeExec } from 'teleport/Console/stores'; + +import type { + ParticipantMode, + Session, + SessionMetadata, +} from 'teleport/services/session'; + +const tracer = trace.getTracer('TTY'); + +export default function useKubeExecSession(doc: DocumentKubeExec) { + const { + clusterId, + sid, + kubeCluster, + kubeNamespace, + pod, + container, + command, + isInteractive, + mode, + } = doc; + const ctx = useConsoleContext(); + const ttyRef = React.useRef(null); + const tty = ttyRef.current as ReturnType; + const [session, setSession] = React.useState(null); + const [status, setStatus] = React.useState('loading'); + + function closeDocument() { + ctx.closeTab(doc); + } + + function sendKubeExecData( + namespace: string, + pod: string, + container: string, + command: string, + isInteractive: boolean + ): void { + tty.sendKubeExecData({ + kubeCluster: doc.kubeCluster, + namespace, + pod, + container, + command, + isInteractive, + }); + setStatus('initialized'); + } + + React.useEffect(() => { + function initTty(session, mode?: ParticipantMode) { + tracer.startActiveSpan( + 'initTTY', + undefined, // SpanOptions + context.active(), + span => { + const tty = ctx.createTty(session, mode); + + // subscribe to tty events to handle connect/disconnects events + tty.on(TermEvent.CLOSE, () => ctx.closeTab(doc)); + + tty.on(TermEvent.CONN_CLOSE, () => { + ctx.updateKubeExecDocument(doc.id, { status: 'disconnected' }); + }); + + tty.on(TermEvent.SESSION, payload => { + const data = JSON.parse(payload); + data.session.kind = 'k8s'; + setSession(data.session); + handleTtyConnect(ctx, data.session, doc.id, isInteractive, command); + }); + + // assign tty reference so it can be passed down to xterm + ttyRef.current = tty; + setSession(session); + setStatus('waiting-for-exec-data'); + span.end(); + } + ); + } + initTty( + { + kind: 'k8s', + resourceName: kubeCluster, + login: [kubeNamespace, pod, container].join('/'), + isInteractive, + command, + clusterId, + sid, + }, + mode + ); + + function teardownTty() { + ttyRef.current && ttyRef.current.removeAllListeners(); + } + + return teardownTty; + }, []); + + return { + tty, + status, + session, + closeDocument, + sendKubeExecData, + }; +} + +function handleTtyConnect( + ctx: ConsoleContext, + session: SessionMetadata, + docId: number, + isInteractive: boolean, + command: string +) { + const { + login, + id: sid, + cluster_name: clusterId, + created, + kubernetes_cluster_name, + } = session; + + const splits = login.split('/'); + + const kubeCluster = kubernetes_cluster_name; + const kubeNamespace = splits[0]; + const pod = splits[1]; + const container = splits?.[2]; + + const url = cfg.getKubeExecSessionRoute({ clusterId, sid }); + const createdDate = new Date(created); + ctx.updateKubeExecDocument(docId, { + title: `${kubeNamespace}/${pod}@${kubeCluster}`, + status: 'connected', + url, + created: createdDate, + kubeNamespace, + kubeCluster, + pod, + container, + isInteractive, + command, + sid, + clusterId, + }); + + ctx.gotoTab({ url }); +} + +export type Status = 'loading' | 'waiting-for-exec-data' | 'initialized'; diff --git a/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx b/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx index 60cc1962ed623..c832e7c625460 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx +++ b/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx @@ -41,6 +41,9 @@ export interface TerminalProps { tty: Tty; fontFamily: string; theme: ITheme; + // convertEol when set to true cursor will be set to the beginning of the next line with every received new line symbol. + // This is equivalent to replacing each '\n' with '\r\n'. + convertEol?: boolean; } export const Terminal = forwardRef((props, ref) => { @@ -64,6 +67,7 @@ export const Terminal = forwardRef((props, ref) => { fontFamily: props.fontFamily, fontSize, theme: props.theme, + convertEol: props.convertEol, }); termCtrlRef.current = termCtrl; @@ -92,6 +96,7 @@ export const Terminal = forwardRef((props, ref) => { width="100%" px="2" style={{ overflow: 'auto' }} + data-testid="terminal" > diff --git a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts index a8d34ca2243c3..d9444d8f74bbf 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts @@ -91,10 +91,12 @@ export default function useSshSession(doc: DocumentSsh) { } initTty( { + kind: 'ssh', login, serverId, clusterId, sid, + kubeExec: doc.kubeExec, }, mode ); diff --git a/web/packages/teleport/src/Console/consoleContext.tsx b/web/packages/teleport/src/Console/consoleContext.tsx index 76ba3013d7cac..a5615a45708b0 100644 --- a/web/packages/teleport/src/Console/consoleContext.tsx +++ b/web/packages/teleport/src/Console/consoleContext.tsx @@ -23,7 +23,11 @@ import { W3CTraceContextPropagator } from '@opentelemetry/core'; import webSession from 'teleport/services/websession'; import history from 'teleport/services/history'; -import cfg, { UrlResourcesParams, UrlSshParams } from 'teleport/config'; +import cfg, { + UrlKubeExecParams, + UrlResourcesParams, + UrlSshParams, +} from 'teleport/config'; import { getHostName } from 'teleport/services/api'; import Tty from 'teleport/lib/term/tty'; import TtyAddressResolver from 'teleport/lib/term/ttyAddressResolver'; @@ -37,7 +41,13 @@ import ClustersService from 'teleport/services/clusters'; import { StoreUserContext } from 'teleport/stores'; import usersService from 'teleport/services/user'; -import { StoreParties, StoreDocs, DocumentSsh, Document } from './stores'; +import { + StoreParties, + StoreDocs, + DocumentSsh, + DocumentKubeExec, + Document, +} from './stores'; const logger = Logger.create('teleport/console'); @@ -89,6 +99,10 @@ export default class ConsoleContext { this.storeDocs.update(id, partial); } + updateKubeExecDocument(id: number, partial: Partial) { + this.storeDocs.update(id, partial); + } + addNodeDocument(clusterId = cfg.proxyCluster) { return this.storeDocs.add({ clusterId, @@ -99,6 +113,27 @@ export default class ConsoleContext { }); } + addKubeExecDocument(params: UrlKubeExecParams) { + const url = this.getKubeExecDocumentUrl(params); + + return this.storeDocs.add({ + kind: 'kubeExec', + status: 'disconnected', + clusterId: params.clusterId, + title: params.kubeId, + url, + created: new Date(), + mode: null, + + kubeCluster: params.kubeId, + kubeNamespace: '', + pod: '', + container: '', + isInteractive: true, + command: '', + }); + } + addSshDocument({ login, serverId, sid, clusterId, mode }: UrlSshParams) { const title = login && serverId ? `${login}@${serverId}` : sid; const url = this.getSshDocumentUrl({ @@ -138,6 +173,10 @@ export default class ConsoleContext { : cfg.getSshConnectRoute(sshParams); } + getKubeExecDocumentUrl(kubeExecParams: UrlKubeExecParams) { + return cfg.getKubeExecConnectRoute(kubeExecParams); + } + refreshParties() { return tracer.startActiveSpan('refreshParties', span => { // Finds unique clusterIds from all active ssh sessions @@ -194,20 +233,31 @@ export default class ConsoleContext { const ctx = context.active(); propagator.inject(ctx, carrier, defaultTextMapSetter); + const baseUrl = + session.kind === 'k8s' ? cfg.api.ttyKubeExecWsAddr : cfg.api.ttyWsAddr; + + let ttyParams = {}; + switch (session.kind) { + case 'ssh': + ttyParams = { + login, + sid, + server_id: serverId, + mode, + }; + break; + case 'k8s': + break; + } - const ttyUrl = cfg.api.ttyWsAddr + const ttyUrl = baseUrl .replace(':fqdn', getHostName()) .replace(':clusterId', clusterId) .replace(':traceparent', carrier['traceparent']); const addressResolver = new TtyAddressResolver({ ttyUrl, - ttyParams: { - login, - sid, - server_id: serverId, - mode, - }, + ttyParams, }); return new Tty(addressResolver); diff --git a/web/packages/teleport/src/Console/stores/storeDocs.ts b/web/packages/teleport/src/Console/stores/storeDocs.ts index 0aba9b0c4d4a3..e1febf0f2971b 100644 --- a/web/packages/teleport/src/Console/stores/storeDocs.ts +++ b/web/packages/teleport/src/Console/stores/storeDocs.ts @@ -85,7 +85,7 @@ export default class StoreDocs extends Store { } findByUrl(url: string) { - return this.state.items.find(i => i.url === encodeURI(url)); + return this.state.items.find(i => encodeURI(i.url.split('?')[0]) === url); } getNodeDocuments() { diff --git a/web/packages/teleport/src/Console/stores/types.ts b/web/packages/teleport/src/Console/stores/types.ts index cec42815d0d37..843ec95ad544f 100644 --- a/web/packages/teleport/src/Console/stores/types.ts +++ b/web/packages/teleport/src/Console/stores/types.ts @@ -23,7 +23,7 @@ interface DocumentBase { title?: string; clusterId?: string; url: string; - kind: 'terminal' | 'nodes' | 'blank'; + kind: 'terminal' | 'nodes' | 'kubeExec' | 'blank'; created: Date; } @@ -34,6 +34,7 @@ export interface DocumentBlank extends DocumentBase { export interface DocumentSsh extends DocumentBase { status: 'connected' | 'disconnected'; kind: 'terminal'; + kubeExec?: boolean; sid?: string; mode?: ParticipantMode; serverId: string; @@ -50,6 +51,23 @@ export interface DocumentNodes extends DocumentBase { kind: 'nodes'; } -export type Document = DocumentNodes | DocumentSsh | DocumentBlank; +export interface DocumentKubeExec extends DocumentBase { + status: 'connected' | 'disconnected'; + kind: 'kubeExec'; + sid?: string; + mode?: ParticipantMode; + kubeCluster: string; + kubeNamespace: string; + pod: string; + container: string; + isInteractive: boolean; + command: string; +} + +export type Document = + | DocumentNodes + | DocumentSsh + | DocumentKubeExec + | DocumentBlank; export type Parties = Record; diff --git a/web/packages/teleport/src/Console/useTabRouting.test.tsx b/web/packages/teleport/src/Console/useTabRouting.test.tsx index 84333381eeab8..86a667f989622 100644 --- a/web/packages/teleport/src/Console/useTabRouting.test.tsx +++ b/web/packages/teleport/src/Console/useTabRouting.test.tsx @@ -65,6 +65,30 @@ test('handling of join ssh session route', async () => { expect(docs).toHaveLength(2); }); +test('handling of init kubeExec session route', async () => { + const ctx = new ConsoleContext(); + const wrapper = makeWrapper( + '/web/cluster/localhost/console/kube/exec/kubeCluster/' + ); + const { current } = renderHook(() => useTabRouting(ctx), { wrapper }); + const docs = ctx.getDocuments(); + expect(docs[1].kind).toBe('kubeExec'); + expect(docs[1].id).toBe(current.activeDocId); + expect(docs).toHaveLength(2); +}); + +test('handling of init kubeExec session route with container', async () => { + const ctx = new ConsoleContext(); + const wrapper = makeWrapper( + '/web/cluster/localhost/console/kube/exec/kubeCluster/' + ); + const { current } = renderHook(() => useTabRouting(ctx), { wrapper }); + const docs = ctx.getDocuments(); + expect(docs[1].kind).toBe('kubeExec'); + expect(docs[1].id).toBe(current.activeDocId); + expect(docs).toHaveLength(2); +}); + test('active document id', async () => { const ctx = new ConsoleContext(); const doc = ctx.addSshDocument({ @@ -81,6 +105,21 @@ test('active document id', async () => { expect(countAfter).toBe(countBefore); }); +test('active document id, document url with query parameters', async () => { + const ctx = new ConsoleContext(); + const doc = ctx.addKubeExecDocument({ + clusterId: 'cluster1', + kubeId: 'kube1', + }); + + const countBefore = ctx.getDocuments(); + const wrapper = makeWrapper('/web/cluster/cluster1/console/kube/exec/kube1/'); + const { current } = renderHook(() => useTabRouting(ctx), { wrapper }); + const countAfter = ctx.getDocuments(); + expect(doc.id).toBe(current.activeDocId); + expect(countAfter).toBe(countBefore); +}); + function makeWrapper(route: string) { return function MockedContextProviders(props: any) { const history = createMemoryHistory({ diff --git a/web/packages/teleport/src/Console/useTabRouting.ts b/web/packages/teleport/src/Console/useTabRouting.ts index b790d83eff858..b80b5203a1c59 100644 --- a/web/packages/teleport/src/Console/useTabRouting.ts +++ b/web/packages/teleport/src/Console/useTabRouting.ts @@ -19,7 +19,7 @@ import React from 'react'; import { useRouteMatch, useParams, useLocation } from 'react-router'; -import cfg, { UrlSshParams } from 'teleport/config'; +import cfg, { UrlKubeExecParams, UrlSshParams } from 'teleport/config'; import { ParticipantMode } from 'teleport/services/session'; import ConsoleContext from './consoleContext'; @@ -28,6 +28,9 @@ export default function useRouting(ctx: ConsoleContext) { const { pathname, search } = useLocation(); const { clusterId } = useParams<{ clusterId: string }>(); const sshRouteMatch = useRouteMatch(cfg.routes.consoleConnect); + const kubeExecRouteMatch = useRouteMatch( + cfg.routes.kubeExec + ); const nodesRouteMatch = useRouteMatch(cfg.routes.consoleNodes); const joinSshRouteMatch = useRouteMatch( cfg.routes.consoleSession @@ -53,6 +56,8 @@ export default function useRouting(ctx: ConsoleContext) { ctx.addSshDocument(joinSshRouteMatch.params); } else if (nodesRouteMatch) { ctx.addNodeDocument(clusterId); + } else if (kubeExecRouteMatch) { + ctx.addKubeExecDocument(kubeExecRouteMatch.params); } }, [ctx, pathname]); diff --git a/web/packages/teleport/src/Kubes/ConnectDialog/ConnectDialog.tsx b/web/packages/teleport/src/Kubes/ConnectDialog/ConnectDialog.tsx index 639b478e791b9..44e0a1fdddecb 100644 --- a/web/packages/teleport/src/Kubes/ConnectDialog/ConnectDialog.tsx +++ b/web/packages/teleport/src/Kubes/ConnectDialog/ConnectDialog.tsx @@ -23,11 +23,12 @@ import Dialog, { DialogTitle, DialogContent, } from 'design/Dialog'; -import { Text, Box, ButtonSecondary } from 'design'; +import { Text, Box, ButtonSecondary, ButtonPrimary, Flex } from 'design'; -import TextSelectCopy from 'teleport/components/TextSelectCopy'; +import { generateTshLoginCommand, openNewTab } from 'teleport/lib/util'; import { AuthType } from 'teleport/services/user'; -import { generateTshLoginCommand } from 'teleport/lib/util'; +import TextSelectCopy from 'teleport/components/TextSelectCopy'; +import cfg from 'teleport/config'; function ConnectDialog(props: Props) { const { @@ -39,6 +40,15 @@ function ConnectDialog(props: Props) { accessRequestId, } = props; + const startKubeExecSession = () => { + const url = cfg.getKubeExecConnectRoute({ + clusterId, + kubeId: kubeConnectName, + }); + + openNewTab(url); + }; + return ( + + Connect in the CLI using tsh and kubectl + Step 1 @@ -99,6 +112,14 @@ function ConnectDialog(props: Props) { )} + + + + Or exec into a pod on this Kubernetes cluster in Web UI + + Exec + + Close diff --git a/web/packages/teleport/src/Kubes/ConnectDialog/__snapshots__/ConnectDialog.story.test.tsx.snap b/web/packages/teleport/src/Kubes/ConnectDialog/__snapshots__/ConnectDialog.story.test.tsx.snap index 6bc861cb0464a..e9f9247a5b49f 100644 --- a/web/packages/teleport/src/Kubes/ConnectDialog/__snapshots__/ConnectDialog.story.test.tsx.snap +++ b/web/packages/teleport/src/Kubes/ConnectDialog/__snapshots__/ConnectDialog.story.test.tsx.snap @@ -67,6 +67,23 @@ exports[`kube connect dialogue local 1`] = ` text-overflow: ellipsis; font-weight: 600; margin: 0px; + margin-bottom: 8px; + margin-top: 4px; +} + +.c11 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; +} + +.c22 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; + margin-top: 4px; } .c4 { @@ -86,7 +103,7 @@ exports[`kube connect dialogue local 1`] = ` margin-bottom: 24px; } -.c11 { +.c12 { box-sizing: border-box; margin-top: 8px; padding: 8px; @@ -95,22 +112,34 @@ exports[`kube connect dialogue local 1`] = ` border-radius: 4px; } -.c13 { +.c14 { box-sizing: border-box; margin-right: 8px; } -.c15 { +.c16 { box-sizing: border-box; margin-right: 4px; } -.c17 { +.c18 { box-sizing: border-box; margin-bottom: 4px; } -.c18 { +.c19 { + box-sizing: border-box; + margin-bottom: 24px; + margin-top: 24px; + border-top: 1px solid; +} + +.c20 { + box-sizing: border-box; + margin-top: 24px; +} + +.c23 { box-sizing: border-box; } @@ -124,17 +153,22 @@ exports[`kube connect dialogue local 1`] = ` flex-direction: column; } -.c12 { +.c13 { display: flex; align-items: center; justify-content: space-between; } -.c14 { +.c15 { display: flex; } -.c16 { +.c21 { + display: flex; + justify-content: space-between; +} + +.c17 { line-height: 1.5; margin: 0; display: inline-flex; @@ -160,22 +194,22 @@ exports[`kube connect dialogue local 1`] = ` padding: 0px 24px; } -.c16:hover, -.c16:focus { +.c17:hover, +.c17:focus { background: #B29DFF; } -.c16:active { +.c17:active { background: #C5B6FF; } -.c16:disabled { +.c17:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; } -.c19 { +.c24 { line-height: 1.5; margin: 0; display: inline-flex; @@ -201,16 +235,16 @@ exports[`kube connect dialogue local 1`] = ` padding: 0px 24px; } -.c19:hover, -.c19:focus { +.c24:hover, +.c24:focus { background: rgba(255,255,255,0.13); } -.c19:active { +.c24:active { background: rgba(255,255,255,0.18); } -.c19:disabled { +.c24:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; @@ -250,22 +284,27 @@ exports[`kube connect dialogue local 1`] = ` - + Connect in the CLI using tsh and kubectl + + Step 1 - Login to Teleport $ @@ -275,7 +314,7 @@ exports[`kube connect dialogue local 1`] = ` @@ -287,22 +326,22 @@ exports[`kube connect dialogue local 1`] = ` class="c9" > Optional - To write kubectl configuration to a separate file instead of having your global kubectl configuration modified, run the following command: $ @@ -312,7 +351,7 @@ exports[`kube connect dialogue local 1`] = ` @@ -324,21 +363,21 @@ exports[`kube connect dialogue local 1`] = ` class="c9" > Step 2 - Select the Kubernetes cluster $ @@ -348,7 +387,7 @@ exports[`kube connect dialogue local 1`] = ` @@ -357,24 +396,24 @@ exports[`kube connect dialogue local 1`] = ` Step 3 - Connect to the Kubernetes cluster $ @@ -384,7 +423,7 @@ exports[`kube connect dialogue local 1`] = ` @@ -392,12 +431,31 @@ exports[`kube connect dialogue local 1`] = ` + + + + Or exec into a pod on this Kubernetes cluster in Web UI + + + Exec + + + Close @@ -476,6 +534,23 @@ exports[`kube connect dialogue local with requestId 1`] = ` text-overflow: ellipsis; font-weight: 600; margin: 0px; + margin-bottom: 8px; + margin-top: 4px; +} + +.c11 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; +} + +.c23 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; + margin-top: 4px; } .c4 { @@ -495,7 +570,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` margin-bottom: 24px; } -.c11 { +.c12 { box-sizing: border-box; margin-top: 8px; padding: 8px; @@ -504,28 +579,40 @@ exports[`kube connect dialogue local with requestId 1`] = ` border-radius: 4px; } -.c13 { +.c14 { box-sizing: border-box; margin-right: 8px; } -.c15 { +.c16 { box-sizing: border-box; margin-right: 4px; } -.c17 { +.c18 { box-sizing: border-box; margin-bottom: 4px; } -.c18 { +.c19 { box-sizing: border-box; margin-bottom: 4px; margin-top: 16px; } -.c19 { +.c20 { + box-sizing: border-box; + margin-bottom: 24px; + margin-top: 24px; + border-top: 1px solid; +} + +.c21 { + box-sizing: border-box; + margin-top: 24px; +} + +.c24 { box-sizing: border-box; } @@ -539,17 +626,22 @@ exports[`kube connect dialogue local with requestId 1`] = ` flex-direction: column; } -.c12 { +.c13 { display: flex; align-items: center; justify-content: space-between; } -.c14 { +.c15 { display: flex; } -.c16 { +.c22 { + display: flex; + justify-content: space-between; +} + +.c17 { line-height: 1.5; margin: 0; display: inline-flex; @@ -575,22 +667,22 @@ exports[`kube connect dialogue local with requestId 1`] = ` padding: 0px 24px; } -.c16:hover, -.c16:focus { +.c17:hover, +.c17:focus { background: #B29DFF; } -.c16:active { +.c17:active { background: #C5B6FF; } -.c16:disabled { +.c17:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; } -.c20 { +.c25 { line-height: 1.5; margin: 0; display: inline-flex; @@ -616,16 +708,16 @@ exports[`kube connect dialogue local with requestId 1`] = ` padding: 0px 24px; } -.c20:hover, -.c20:focus { +.c25:hover, +.c25:focus { background: rgba(255,255,255,0.13); } -.c20:active { +.c25:active { background: rgba(255,255,255,0.18); } -.c20:disabled { +.c25:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; @@ -665,22 +757,27 @@ exports[`kube connect dialogue local with requestId 1`] = ` - + Connect in the CLI using tsh and kubectl + + Step 1 - Login to Teleport $ @@ -690,7 +787,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` @@ -702,22 +799,22 @@ exports[`kube connect dialogue local with requestId 1`] = ` class="c9" > Optional - To write kubectl configuration to a separate file instead of having your global kubectl configuration modified, run the following command: $ @@ -727,7 +824,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` @@ -739,21 +836,21 @@ exports[`kube connect dialogue local with requestId 1`] = ` class="c9" > Step 2 - Select the Kubernetes cluster $ @@ -763,7 +860,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` @@ -772,24 +869,24 @@ exports[`kube connect dialogue local with requestId 1`] = ` Step 3 - Connect to the Kubernetes cluster $ @@ -799,7 +896,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` @@ -808,24 +905,24 @@ exports[`kube connect dialogue local with requestId 1`] = ` Step 4 (Optional) - When finished, drop the assumed role $ @@ -835,7 +932,7 @@ exports[`kube connect dialogue local with requestId 1`] = ` @@ -843,12 +940,31 @@ exports[`kube connect dialogue local with requestId 1`] = ` + + + + Or exec into a pod on this Kubernetes cluster in Web UI + + + Exec + + + Close @@ -927,6 +1043,23 @@ exports[`kube connect dialogue sso 1`] = ` text-overflow: ellipsis; font-weight: 600; margin: 0px; + margin-bottom: 8px; + margin-top: 4px; +} + +.c11 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; +} + +.c22 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + margin: 0px; + margin-top: 4px; } .c4 { @@ -946,7 +1079,7 @@ exports[`kube connect dialogue sso 1`] = ` margin-bottom: 24px; } -.c11 { +.c12 { box-sizing: border-box; margin-top: 8px; padding: 8px; @@ -955,22 +1088,34 @@ exports[`kube connect dialogue sso 1`] = ` border-radius: 4px; } -.c13 { +.c14 { box-sizing: border-box; margin-right: 8px; } -.c15 { +.c16 { box-sizing: border-box; margin-right: 4px; } -.c17 { +.c18 { box-sizing: border-box; margin-bottom: 4px; } -.c18 { +.c19 { + box-sizing: border-box; + margin-bottom: 24px; + margin-top: 24px; + border-top: 1px solid; +} + +.c20 { + box-sizing: border-box; + margin-top: 24px; +} + +.c23 { box-sizing: border-box; } @@ -984,17 +1129,22 @@ exports[`kube connect dialogue sso 1`] = ` flex-direction: column; } -.c12 { +.c13 { display: flex; align-items: center; justify-content: space-between; } -.c14 { +.c15 { display: flex; } -.c16 { +.c21 { + display: flex; + justify-content: space-between; +} + +.c17 { line-height: 1.5; margin: 0; display: inline-flex; @@ -1020,22 +1170,22 @@ exports[`kube connect dialogue sso 1`] = ` padding: 0px 24px; } -.c16:hover, -.c16:focus { +.c17:hover, +.c17:focus { background: #B29DFF; } -.c16:active { +.c17:active { background: #C5B6FF; } -.c16:disabled { +.c17:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; } -.c19 { +.c24 { line-height: 1.5; margin: 0; display: inline-flex; @@ -1061,16 +1211,16 @@ exports[`kube connect dialogue sso 1`] = ` padding: 0px 24px; } -.c19:hover, -.c19:focus { +.c24:hover, +.c24:focus { background: rgba(255,255,255,0.13); } -.c19:active { +.c24:active { background: rgba(255,255,255,0.18); } -.c19:disabled { +.c24:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); cursor: auto; @@ -1110,22 +1260,27 @@ exports[`kube connect dialogue sso 1`] = ` - + Connect in the CLI using tsh and kubectl + + Step 1 - Login to Teleport $ @@ -1135,7 +1290,7 @@ exports[`kube connect dialogue sso 1`] = ` @@ -1147,22 +1302,22 @@ exports[`kube connect dialogue sso 1`] = ` class="c9" > Optional - To write kubectl configuration to a separate file instead of having your global kubectl configuration modified, run the following command: $ @@ -1172,7 +1327,7 @@ exports[`kube connect dialogue sso 1`] = ` @@ -1184,21 +1339,21 @@ exports[`kube connect dialogue sso 1`] = ` class="c9" > Step 2 - Select the Kubernetes cluster $ @@ -1208,7 +1363,7 @@ exports[`kube connect dialogue sso 1`] = ` @@ -1217,24 +1372,24 @@ exports[`kube connect dialogue sso 1`] = ` Step 3 - Connect to the Kubernetes cluster $ @@ -1244,7 +1399,7 @@ exports[`kube connect dialogue sso 1`] = ` @@ -1252,12 +1407,31 @@ exports[`kube connect dialogue sso 1`] = ` + + + + Or exec into a pod on this Kubernetes cluster in Web UI + + + Exec + + + Close diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 35fed5d9aeefb..4069284503963 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -159,6 +159,8 @@ const cfg = { consoleNodes: '/web/cluster/:clusterId/console/nodes', consoleConnect: '/web/cluster/:clusterId/console/node/:serverId/:login', consoleSession: '/web/cluster/:clusterId/console/session/:sid', + kubeExec: '/web/cluster/:clusterId/console/kube/exec/:kubeId/', + kubeExecSession: '/web/cluster/:clusterId/console/kube/exec/:sid', player: '/web/cluster/:clusterId/session/:sid', // ?recordingType=ssh|desktop|k8s&durationMs=1234 login: '/web/login', loginSuccess: '/web/msg/info/login_success', @@ -230,6 +232,8 @@ const cfg = { desktopIsActive: '/v1/webapi/sites/:clusterId/desktops/:desktopName/active', ttyWsAddr: 'wss://:fqdn/v1/webapi/sites/:clusterId/connect/ws?params=:params&traceparent=:traceparent', + ttyKubeExecWsAddr: + 'wss://:fqdn/v1/webapi/sites/:clusterId/kube/exec/ws?params=:params&traceparent=:traceparent', ttyPlaybackWsAddr: 'wss://:fqdn/v1/webapi/sites/:clusterId/ttyplayback/:sid?access_token=:token', // TODO(zmb3): get token out of URL activeAndPendingSessionsPath: '/v1/webapi/sites/:clusterId/sessions', @@ -558,6 +562,10 @@ const cfg = { }); }, + getKubeExecConnectRoute(params: UrlKubeExecParams) { + return generatePath(cfg.routes.kubeExec, { ...params }); + }, + getDesktopRoute({ clusterId, username, desktopName }) { return generatePath(cfg.routes.desktop, { clusterId, @@ -566,6 +574,20 @@ const cfg = { }); }, + getKubeExecSessionRoute( + { clusterId, sid }: UrlParams, + mode?: ParticipantMode + ) { + const basePath = generatePath(cfg.routes.kubeExecSession, { + clusterId, + sid, + }); + if (mode) { + return `${basePath}?mode=${mode}`; + } + return basePath; + }, + getSshSessionRoute({ clusterId, sid }: UrlParams, mode?: ParticipantMode) { const basePath = generatePath(cfg.routes.consoleSession, { clusterId, @@ -1108,6 +1130,11 @@ export interface UrlSshParams { clusterId: string; } +export interface UrlKubeExecParams { + clusterId: string; + kubeId: string; +} + export interface UrlSessionRecordingsParams { start: string; end: string; diff --git a/web/packages/teleport/src/lib/term/enums.ts b/web/packages/teleport/src/lib/term/enums.ts index f301e3f62e290..277ce3fd46565 100644 --- a/web/packages/teleport/src/lib/term/enums.ts +++ b/web/packages/teleport/src/lib/term/enums.ts @@ -38,6 +38,7 @@ export enum TermEvent { CONN_CLOSE = 'connection.close', WEBAUTHN_CHALLENGE = 'terminal.webauthn', LATENCY = 'terminal.latency', + KUBE_EXEC = 'terminal.kube_exec', } // Websocket connection close codes. diff --git a/web/packages/teleport/src/lib/term/protobuf.js b/web/packages/teleport/src/lib/term/protobuf.js index fa319a4a2da0e..ee79b76cd5e3b 100644 --- a/web/packages/teleport/src/lib/term/protobuf.js +++ b/web/packages/teleport/src/lib/term/protobuf.js @@ -33,6 +33,7 @@ export const MessageTypeEnum = { FILE_TRANSFER_DECISION: 't', WEBAUTHN_CHALLENGE: 'n', LATENCY: 'l', + KUBE_EXEC: 'k', }; export const messageFields = { @@ -60,6 +61,7 @@ export const messageFields = { event: MessageTypeEnum.AUDIT.charCodeAt(0), close: MessageTypeEnum.SESSION_END.charCodeAt(0), challengeResponse: MessageTypeEnum.WEBAUTHN_CHALLENGE.charCodeAt(0), + kubeExec: MessageTypeEnum.KUBE_EXEC.charCodeAt(0), }, }, }; @@ -89,6 +91,10 @@ export class Protobuf { return this.encode(messageFields.type.values.fileTransferDecision, message); } + encodeKubeExecData(message) { + return this.encode(messageFields.type.values.kubeExec, message); + } + encodeRawMessage(message) { return this.encode(messageFields.type.values.data, message); } diff --git a/web/packages/teleport/src/lib/term/terminal.ts b/web/packages/teleport/src/lib/term/terminal.ts index 5744d30f15b5a..7816a78f92f2c 100644 --- a/web/packages/teleport/src/lib/term/terminal.ts +++ b/web/packages/teleport/src/lib/term/terminal.ts @@ -47,6 +47,7 @@ export default class TtyTerminal { _scrollBack: number; _fontFamily: string; _fontSize: number; + _convertEol: boolean; _debouncedResize: DebouncedFunc<() => void>; _fitAddon = new FitAddon(); _webLinksAddon = new WebLinksAddon(); @@ -57,13 +58,14 @@ export default class TtyTerminal { tty: Tty, private options: Options ) { - const { el, scrollBack, fontFamily, fontSize } = options; + const { el, scrollBack, fontFamily, fontSize, convertEol } = options; this._el = el; this._fontFamily = fontFamily || undefined; this._fontSize = fontSize || 14; // Passing scrollback will overwrite the default config. This is to support ttyplayer. // Default to the config when not passed anything, which is the normal usecase this._scrollBack = scrollBack || cfg.ui.scrollbackLines; + this._convertEol = convertEol || false; this.tty = tty; this.term = null; @@ -78,6 +80,7 @@ export default class TtyTerminal { fontFamily: this._fontFamily, fontSize: this._fontSize, scrollback: this._scrollBack, + convertEol: this._convertEol, cursorBlink: false, minimumContrastRatio: 4.5, // minimum for WCAG AA compliance theme: this.options.theme, @@ -233,4 +236,5 @@ type Options = { scrollBack?: number; fontFamily?: string; fontSize?: number; + convertEol?: boolean; }; diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index fe45eb930d65a..e6913183d3848 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -86,6 +86,12 @@ class Tty extends EventEmitterWebAuthnSender { this.socket.send(bytearray); } + sendKubeExecData(data: KubeExecData) { + const encoded = this._proto.encodeKubeExecData(JSON.stringify(data)); + const bytearray = new Uint8Array(encoded); + this.socket.send(bytearray); + } + _sendFileTransferRequest(message: string) { const encoded = this._proto.encodeFileTransferRequest(message); const bytearray = new Uint8Array(encoded); @@ -258,4 +264,13 @@ class Tty extends EventEmitterWebAuthnSender { } } +export type KubeExecData = { + kubeCluster: string; + namespace: string; + pod: string; + container: string; + command: string; + isInteractive: boolean; +}; + export default Tty; diff --git a/web/packages/teleport/src/services/session/types.ts b/web/packages/teleport/src/services/session/types.ts index 60d533dab951e..8665170504eba 100644 --- a/web/packages/teleport/src/services/session/types.ts +++ b/web/packages/teleport/src/services/session/types.ts @@ -31,6 +31,7 @@ export interface Session { clusterId: string; parties: Participant[]; addr: string; + kubeExec?: boolean; // resourceName depending on the "kind" field, is the name // of resource that the session is running in: // - ssh: is referring to the hostname @@ -45,6 +46,7 @@ export interface Session { moderated: boolean; // command is the command that was run to start this session. command: string; + isInteractive?: boolean; } export type SessionMetadata = {