diff --git a/lib/client/api.go b/lib/client/api.go index 88693bc768bb4..6cc0caae15286 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -5032,19 +5032,6 @@ func findActiveApps(keyRing *KeyRing) ([]tlsca.RouteToApp, error) { return apps, nil } -// getDesktopEventWebURL returns the web UI URL users can access to -// watch a desktop session recording in the browser -func getDesktopEventWebURL(proxyHost string, cluster string, sid *session.ID, events []events.EventFields) string { - if len(events) < 1 { - return "" - } - start := events[0].GetTimestamp() - end := events[len(events)-1].GetTimestamp() - duration := end.Sub(start) - - return fmt.Sprintf("https://%s/web/cluster/%s/session/%s?recordingType=desktop&durationMs=%d", proxyHost, cluster, sid, duration/time.Millisecond) -} - // SearchSessionEvents allows searching for session events with a full pagination support. func (tc *TeleportClient) SearchSessionEvents(ctx context.Context, fromUTC, toUTC time.Time, pageSize int, order types.EventOrder, max int) ([]apievents.AuditEvent, error) { ctx, span := tc.Tracer.Start( diff --git a/lib/client/api_test.go b/lib/client/api_test.go index 9da016c7af401..136d338b01e39 100644 --- a/lib/client/api_test.go +++ b/lib/client/api_test.go @@ -27,7 +27,6 @@ import ( "math" "os" "testing" - "time" "github.com/coreos/go-semver/semver" "github.com/google/go-cmp/cmp" @@ -45,10 +44,8 @@ import ( "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/defaults" - "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/observability/tracing" - "github.com/gravitational/teleport/lib/session" "github.com/gravitational/teleport/lib/utils" ) @@ -929,78 +926,6 @@ func TestFormatConnectToProxyErr(t *testing.T) { } } -func TestGetDesktopEventWebURL(t *testing.T) { - initDate := time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC) - - tt := []struct { - name string - proxyHost string - cluster string - sid session.ID - events []events.EventFields - expected string - }{ - { - name: "nil events", - events: nil, - expected: "", - }, - { - name: "empty events", - events: make([]events.EventFields, 0), - expected: "", - }, - { - name: "two events, 1000 ms duration", - proxyHost: "host", - cluster: "cluster", - sid: "session_id", - events: []events.EventFields{ - { - "time": initDate, - }, - { - "time": initDate.Add(1000 * time.Millisecond), - }, - }, - expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=1000", - }, - { - name: "multiple events", - proxyHost: "host", - cluster: "cluster", - sid: "session_id", - events: []events.EventFields{ - { - "time": initDate, - }, - { - "time": initDate.Add(10 * time.Millisecond), - }, - { - "time": initDate.Add(20 * time.Millisecond), - }, - { - "time": initDate.Add(30 * time.Millisecond), - }, - { - "time": initDate.Add(40 * time.Millisecond), - }, - { - "time": initDate.Add(50 * time.Millisecond), - }, - }, - expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=50", - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.expected, getDesktopEventWebURL(tc.proxyHost, tc.cluster, &tc.sid, tc.events)) - }) - } -} - type mockRoleGetter func(ctx context.Context) ([]types.Role, error) func (m mockRoleGetter) GetRoles(ctx context.Context) ([]types.Role, error) { diff --git a/lib/events/auditlog.go b/lib/events/auditlog.go index 51180746cbe7f..3570171f40996 100644 --- a/lib/events/auditlog.go +++ b/lib/events/auditlog.go @@ -555,6 +555,7 @@ func (l *AuditLog) StreamSessionEvents(ctx context.Context, sessionID session.ID } protoReader := NewProtoReader(rawSession) + defer protoReader.Close() for { if ctx.Err() != nil { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d674d094248c8..a2f8ab1220a4d 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -839,6 +839,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events h.GET("/webapi/sites/:site/events/search/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle)) + h.GET("/webapi/sites/:site/sessionlength/:sid", h.WithClusterAuth(h.sessionLengthHandle)) // scp file transfer h.GET("/webapi/sites/:site/nodes/:server/:login/scp", h.WithClusterAuth(h.transferFile)) diff --git a/lib/web/tty_playback.go b/lib/web/tty_playback.go index f601f4237666c..76c456a4a7010 100644 --- a/lib/web/tty_playback.go +++ b/lib/web/tty_playback.go @@ -53,6 +53,47 @@ const ( actionPause = byte(1) ) +func (h *Handler) sessionLengthHandle( + w http.ResponseWriter, + r *http.Request, + p httprouter.Params, + sctx *SessionContext, + site reversetunnelclient.RemoteSite, +) (interface{}, error) { + sID := p.ByName("sid") + if sID == "" { + return nil, trace.BadParameter("missing session ID in request URL") + } + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + clt, err := sctx.GetUserClient(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + evts, errs := clt.StreamSessionEvents(ctx, session.ID(sID), 0) + for { + select { + case err := <-errs: + return nil, trace.Wrap(err) + case evt, ok := <-evts: + if !ok { + return nil, trace.NotFound("could not find end event for session %v", sID) + } + switch evt := evt.(type) { + case *events.SessionEnd: + return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil + case *events.WindowsDesktopSessionEnd: + return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil + case *events.DatabaseSessionEnd: + return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil + } + } + } +} + func (h *Handler) ttyPlaybackHandle( w http.ResponseWriter, r *http.Request, diff --git a/web/packages/teleport/src/Player/Player.tsx b/web/packages/teleport/src/Player/Player.tsx index d6e251e8ea7ae..48d043a4d89f9 100644 --- a/web/packages/teleport/src/Player/Player.tsx +++ b/web/packages/teleport/src/Player/Player.tsx @@ -16,19 +16,19 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; -import { Flex, Box } from 'design'; - +import { Flex, Box, Indicator } from 'design'; import { Danger } from 'design/Alert'; -import { useParams, useLocation } from 'teleport/components/Router'; +import { makeSuccessAttempt, useAsync } from 'shared/hooks/useAsync'; +import { useParams, useLocation } from 'teleport/components/Router'; import session from 'teleport/services/websession'; -import { UrlPlayerParams } from 'teleport/config'; +import cfg, { UrlPlayerParams } from 'teleport/config'; import { getUrlParameter } from 'teleport/services/history'; - +import api from 'teleport/services/api'; import { RecordingType } from 'teleport/services/recordings'; import ActionBar from './ActionBar'; @@ -42,16 +42,40 @@ export function Player() { const { sid, clusterId } = useParams(); const { search } = useLocation(); + useEffect(() => { + document.title = `Play ${sid} • ${clusterId}`; + }, [sid, clusterId]); + const recordingType = getUrlParameter( 'recordingType', search ) as RecordingType; - const durationMs = Number(getUrlParameter('durationMs', search)); + + // In order to render the progress bar, we need to know the length of the session. + // All in-product links to the session player should include the session duration in the URL. + // Some users manually build the URL based on the session ID and don't specify the session duration. + // For those cases, we make a separate API call to get the duration. + const [fetchDurationAttempt, fetchDuration] = useAsync( + useCallback(async () => { + const response = await fetchSessionDuration(clusterId, sid); + return response; + }, [clusterId, sid]) + ); const validRecordingType = validRecordingTypes.includes(recordingType); - const validDurationMs = Number.isInteger(durationMs) && durationMs > 0; + const durationMs = Number(getUrlParameter('durationMs', search)); + const shouldFetchSessionDuration = + !Number.isInteger(durationMs) || durationMs <= 0; - document.title = `Play ${sid} • ${clusterId}`; + useEffect(() => { + if (shouldFetchSessionDuration) { + fetchDuration(); + } + }, [fetchDuration, shouldFetchSessionDuration]); + + const combinedAttempt = fetchDurationAttempt.status + ? fetchDurationAttempt + : makeSuccessAttempt({ durationMs }); function onLogout() { session.logout(); @@ -70,13 +94,20 @@ export function Player() { ); } - if (!validDurationMs) { + if (combinedAttempt.status === 'processing') { + return ( + + + + ); + } + if (combinedAttempt.status === 'error') { return ( - Invalid query parameter durationMs:{' '} - {getUrlParameter('durationMs', search)}, should be an integer. + Unable to determine the length of this session. The session + recording may be incomplete or corrupted. @@ -102,15 +133,20 @@ export function Player() { ) : ( - + )} ); } + const StyledPlayer = styled.div` display: flex; height: 100%; @@ -122,3 +158,11 @@ const StyledPlayer = styled.div` overflow-y: hidden !important; } `; + +async function fetchSessionDuration( + clusterId: string, + sessionId: string +): Promise<{ durationMs: number }> { + const res = await api.get(cfg.getSessionDurationUrl(clusterId, sessionId)); + return res; +} diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 887d62c875ff5..220d8ffc63f90 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -17,8 +17,8 @@ */ import { generatePath } from 'react-router'; -import { mergeDeep } from 'shared/utils/highbar'; import { IncludedResourceMode } from 'shared/components/UnifiedResources'; +import { mergeDeep } from 'shared/utils/highbar'; import generateResourcePath from './generateResourcePath'; @@ -34,15 +34,15 @@ import type { import type { SortType } from 'teleport/services/agents'; import type { RecordingType } from 'teleport/services/recordings'; +import type { ParticipantMode } from 'teleport/services/session'; import type { WebauthnAssertionResponse } from './services/auth'; import type { + AwsOidcPolicyPreset, PluginKind, Regions, - AwsOidcPolicyPreset, } from './services/integrations'; -import type { ParticipantMode } from 'teleport/services/session'; -import type { YamlSupportedResourceKind } from './services/yaml/types'; import type { KubeResourceKind } from './services/kube/types'; +import type { YamlSupportedResourceKind } from './services/yaml/types'; const cfg = { /** @deprecated Use cfg.edition instead. */ @@ -260,6 +260,7 @@ const cfg = { 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', + sessionDurationPath: '/v1/webapi/sites/:clusterId/sessionlength/:sid', kubernetesPath: '/v1/webapi/sites/:clusterId/kubernetes?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', @@ -760,6 +761,10 @@ const cfg = { return generatePath(cfg.api.activeAndPendingSessionsPath, { clusterId }); }, + getSessionDurationUrl(clusterId: string, sid: string) { + return generatePath(cfg.api.sessionDurationPath, { clusterId, sid }); + }, + getUnifiedResourcesUrl(clusterId: string, params: UrlResourcesParams) { return generateResourcePath(cfg.api.unifiedResourcesPath, { clusterId,