Skip to content

Commit d47b6d8

Browse files
authored
Fix web session playback when a duration is not provided (#50461)
Prior to Teleport 15, the web UI would download the entire session recording into browser memory before playing it back (crashing the browser tab for long sessions). Starting with Teleport 15, session playback is streamed to the browser and played back as it is received instead of waiting for the entire session to be available. A side effect of this change is that the browser needs to know the length of the session in order to render the progress bar during playback. Since the browser starts playing the session before it has received all of it, we started providing the length of the session via a URL query parameter. Some users have grown accustomed to being able to access session recordings at their original URLs (without the duration query parameter). If you attempt to play recordings from these URLs after upgrading to v15, you'll get an error that the duration is missing. To fix this, the web UI needs to request the duration of the session before it can begin playing it (unless the duration is provided via the URL). There are two ways we could get this information: 1. By querying Teleport's audit log 2. By reading the recording twice: once to get to the end event and compute the duration, and a second time to actually play it back. Since we only have a session ID, an audit log query would be inefficient - we have no idea when the session occurred, so we'd have to search from the beginning of time. (This could be resolved by using a UUIDv7 for session IDs, but Teleport uses UUIDv4 today). For this reason, we elect option 2. This commit creates a new web API endpoint that will fetch a session recording file and scan through it in the same way that streaming is done, but instaed of streaming the data through a websocket it simply reads through to the end to compute the session length. The benefit of this approach is that it will generally be faster than option 1 (unless the session is very long), and it effectively pre-downloads the recording file on the Note: option 2 is not without its drawbacks - since the web UI is making two requests that both read the session recording, the audit log will show two separate session_recording.access events. This isn't ideal but it is good enough to get playback working again for customers who don't access playbacks by clicking the "Play" button in the UI.
1 parent e2ee237 commit d47b6d8

File tree

9 files changed

+124
-112
lines changed

9 files changed

+124
-112
lines changed

lib/client/api.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4950,19 +4950,6 @@ func findActiveApps(key *Key) ([]tlsca.RouteToApp, error) {
49504950
return apps, nil
49514951
}
49524952

4953-
// getDesktopEventWebURL returns the web UI URL users can access to
4954-
// watch a desktop session recording in the browser
4955-
func getDesktopEventWebURL(proxyHost string, cluster string, sid *session.ID, events []events.EventFields) string {
4956-
if len(events) < 1 {
4957-
return ""
4958-
}
4959-
start := events[0].GetTimestamp()
4960-
end := events[len(events)-1].GetTimestamp()
4961-
duration := end.Sub(start)
4962-
4963-
return fmt.Sprintf("https://%s/web/cluster/%s/session/%s?recordingType=desktop&durationMs=%d", proxyHost, cluster, sid, duration/time.Millisecond)
4964-
}
4965-
49664953
// SearchSessionEvents allows searching for session events with a full pagination support.
49674954
func (tc *TeleportClient) SearchSessionEvents(ctx context.Context, fromUTC, toUTC time.Time, pageSize int, order types.EventOrder, max int) ([]apievents.AuditEvent, error) {
49684955
ctx, span := tc.Tracer.Start(

lib/client/api_test.go

Lines changed: 0 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"math"
2828
"os"
2929
"testing"
30-
"time"
3130

3231
"github.com/google/go-cmp/cmp"
3332
"github.com/gravitational/trace"
@@ -43,10 +42,8 @@ import (
4342
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
4443
"github.com/gravitational/teleport/api/utils/keys"
4544
"github.com/gravitational/teleport/lib/defaults"
46-
"github.com/gravitational/teleport/lib/events"
4745
"github.com/gravitational/teleport/lib/modules"
4846
"github.com/gravitational/teleport/lib/observability/tracing"
49-
"github.com/gravitational/teleport/lib/session"
5047
"github.com/gravitational/teleport/lib/utils"
5148
)
5249

@@ -906,78 +903,6 @@ func TestFormatConnectToProxyErr(t *testing.T) {
906903
}
907904
}
908905

909-
func TestGetDesktopEventWebURL(t *testing.T) {
910-
initDate := time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)
911-
912-
tt := []struct {
913-
name string
914-
proxyHost string
915-
cluster string
916-
sid session.ID
917-
events []events.EventFields
918-
expected string
919-
}{
920-
{
921-
name: "nil events",
922-
events: nil,
923-
expected: "",
924-
},
925-
{
926-
name: "empty events",
927-
events: make([]events.EventFields, 0),
928-
expected: "",
929-
},
930-
{
931-
name: "two events, 1000 ms duration",
932-
proxyHost: "host",
933-
cluster: "cluster",
934-
sid: "session_id",
935-
events: []events.EventFields{
936-
{
937-
"time": initDate,
938-
},
939-
{
940-
"time": initDate.Add(1000 * time.Millisecond),
941-
},
942-
},
943-
expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=1000",
944-
},
945-
{
946-
name: "multiple events",
947-
proxyHost: "host",
948-
cluster: "cluster",
949-
sid: "session_id",
950-
events: []events.EventFields{
951-
{
952-
"time": initDate,
953-
},
954-
{
955-
"time": initDate.Add(10 * time.Millisecond),
956-
},
957-
{
958-
"time": initDate.Add(20 * time.Millisecond),
959-
},
960-
{
961-
"time": initDate.Add(30 * time.Millisecond),
962-
},
963-
{
964-
"time": initDate.Add(40 * time.Millisecond),
965-
},
966-
{
967-
"time": initDate.Add(50 * time.Millisecond),
968-
},
969-
},
970-
expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=50",
971-
},
972-
}
973-
974-
for _, tc := range tt {
975-
t.Run(tc.name, func(t *testing.T) {
976-
require.Equal(t, tc.expected, getDesktopEventWebURL(tc.proxyHost, tc.cluster, &tc.sid, tc.events))
977-
})
978-
}
979-
}
980-
981906
type mockRoleGetter func(ctx context.Context) ([]types.Role, error)
982907

983908
func (m mockRoleGetter) GetRoles(ctx context.Context) ([]types.Role, error) {

lib/events/auditlog.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ func (l *AuditLog) StreamSessionEvents(ctx context.Context, sessionID session.ID
10241024
}
10251025

10261026
protoReader := NewProtoReader(rawSession)
1027+
defer protoReader.Close()
10271028

10281029
for {
10291030
if ctx.Err() != nil {

lib/web/apiserver.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,7 @@ func (h *Handler) bindDefaultEndpoints() {
854854
h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events
855855
h.GET("/webapi/sites/:site/events/search/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events
856856
h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle))
857+
h.GET("/webapi/sites/:site/sessionlength/:sid", h.WithClusterAuth(h.sessionLengthHandle))
857858

858859
// TODO(zmb3): remove these endpoints when Assist is no longer using them
859860
// (assist calls the proxy's web API, and the proxy uses an HTTP client to call auth's API)

lib/web/tty_playback.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,47 @@ const (
5353
actionPause = byte(1)
5454
)
5555

56+
func (h *Handler) sessionLengthHandle(
57+
w http.ResponseWriter,
58+
r *http.Request,
59+
p httprouter.Params,
60+
sctx *SessionContext,
61+
site reversetunnelclient.RemoteSite,
62+
) (interface{}, error) {
63+
sID := p.ByName("sid")
64+
if sID == "" {
65+
return nil, trace.BadParameter("missing session ID in request URL")
66+
}
67+
68+
ctx, cancel := context.WithCancel(r.Context())
69+
defer cancel()
70+
71+
clt, err := sctx.GetUserClient(ctx, site)
72+
if err != nil {
73+
return nil, trace.Wrap(err)
74+
}
75+
76+
evts, errs := clt.StreamSessionEvents(ctx, session.ID(sID), 0)
77+
for {
78+
select {
79+
case err := <-errs:
80+
return nil, trace.Wrap(err)
81+
case evt, ok := <-evts:
82+
if !ok {
83+
return nil, trace.NotFound("could not find end event for session %v", sID)
84+
}
85+
switch evt := evt.(type) {
86+
case *events.SessionEnd:
87+
return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil
88+
case *events.WindowsDesktopSessionEnd:
89+
return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil
90+
case *events.DatabaseSessionEnd:
91+
return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil
92+
}
93+
}
94+
}
95+
}
96+
5697
func (h *Handler) ttyPlaybackHandle(
5798
w http.ResponseWriter,
5899
r *http.Request,

web/packages/teleport/src/Player/Player.tsx

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,23 @@
1616
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
*/
1818

19-
import React from 'react';
20-
import styled from 'styled-components';
19+
import React, { useCallback, useEffect } from 'react';
2120

22-
import { Flex, Box } from 'design';
21+
import styled from 'styled-components';
2322

23+
import { Flex, Box, Indicator } from 'design';
2424
import { Danger } from 'design/Alert';
2525

26-
import { useParams, useLocation } from 'teleport/components/Router';
26+
import { makeSuccessAttempt, useAsync } from 'shared/hooks/useAsync';
2727

28+
import { useParams, useLocation } from 'teleport/components/Router';
2829
import session from 'teleport/services/websession';
2930
import { UrlPlayerParams } from 'teleport/config';
3031
import { getUrlParameter } from 'teleport/services/history';
31-
3232
import { RecordingType } from 'teleport/services/recordings';
3333

34+
import useTeleport from 'teleport/useTeleport';
35+
3436
import ActionBar from './ActionBar';
3537
import { DesktopPlayer } from './DesktopPlayer';
3638
import SshPlayer from './SshPlayer';
@@ -39,19 +41,44 @@ import Tabs, { TabItem } from './PlayerTabs';
3941
const validRecordingTypes = ['ssh', 'k8s', 'desktop', 'database'];
4042

4143
export function Player() {
44+
const ctx = useTeleport();
4245
const { sid, clusterId } = useParams<UrlPlayerParams>();
4346
const { search } = useLocation();
4447

48+
useEffect(() => {
49+
document.title = `Play ${sid}${clusterId}`;
50+
}, [sid, clusterId]);
51+
4552
const recordingType = getUrlParameter(
4653
'recordingType',
4754
search
4855
) as RecordingType;
49-
const durationMs = Number(getUrlParameter('durationMs', search));
56+
57+
// In order to render the progress bar, we need to know the length of the session.
58+
// All in-product links to the session player should include the session duration in the URL.
59+
// Some users manually build the URL based on the session ID and don't specify the session duration.
60+
// For those cases, we make a separate API call to get the duration.
61+
const [fetchDurationAttempt, fetchDuration] = useAsync(
62+
useCallback(
63+
() => ctx.recordingsService.fetchRecordingDuration(clusterId, sid),
64+
[ctx.recordingsService, clusterId, sid]
65+
)
66+
);
5067

5168
const validRecordingType = validRecordingTypes.includes(recordingType);
52-
const validDurationMs = Number.isInteger(durationMs) && durationMs > 0;
69+
const durationMs = Number(getUrlParameter('durationMs', search));
70+
const shouldFetchSessionDuration =
71+
validRecordingType && (!Number.isInteger(durationMs) || durationMs <= 0);
72+
73+
useEffect(() => {
74+
if (shouldFetchSessionDuration) {
75+
fetchDuration();
76+
}
77+
}, [fetchDuration, shouldFetchSessionDuration]);
5378

54-
document.title = `Play ${sid}${clusterId}`;
79+
const combinedAttempt = shouldFetchSessionDuration
80+
? fetchDurationAttempt
81+
: makeSuccessAttempt({ durationMs });
5582

5683
function onLogout() {
5784
session.logout();
@@ -70,13 +97,25 @@ export function Player() {
7097
);
7198
}
7299

73-
if (!validDurationMs) {
100+
if (
101+
combinedAttempt.status === '' ||
102+
combinedAttempt.status === 'processing'
103+
) {
104+
return (
105+
<StyledPlayer>
106+
<Box textAlign="center" mx={10} mt={5}>
107+
<Indicator />
108+
</Box>
109+
</StyledPlayer>
110+
);
111+
}
112+
if (combinedAttempt.status === 'error') {
74113
return (
75114
<StyledPlayer>
76115
<Box textAlign="center" mx={10} mt={5}>
77116
<Danger mb={0}>
78-
Invalid query parameter durationMs:{' '}
79-
{getUrlParameter('durationMs', search)}, should be an integer.
117+
Unable to determine the length of this session. The session
118+
recording may be incomplete or corrupted.
80119
</Danger>
81120
</Box>
82121
</StyledPlayer>
@@ -102,15 +141,20 @@ export function Player() {
102141
<DesktopPlayer
103142
sid={sid}
104143
clusterId={clusterId}
105-
durationMs={durationMs}
144+
durationMs={combinedAttempt.data.durationMs}
106145
/>
107146
) : (
108-
<SshPlayer sid={sid} clusterId={clusterId} durationMs={durationMs} />
147+
<SshPlayer
148+
sid={sid}
149+
clusterId={clusterId}
150+
durationMs={combinedAttempt.data.durationMs}
151+
/>
109152
)}
110153
</Flex>
111154
</StyledPlayer>
112155
);
113156
}
157+
114158
const StyledPlayer = styled.div`
115159
display: flex;
116160
height: 100%;

web/packages/teleport/src/config.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,30 @@
1717
*/
1818

1919
import { generatePath } from 'react-router';
20-
import { mergeDeep } from 'shared/utils/highbar';
21-
import { IncludedResourceMode } from 'shared/components/UnifiedResources';
2220

23-
import generateResourcePath from './generateResourcePath';
21+
import { IncludedResourceMode } from 'shared/components/UnifiedResources';
2422

25-
import { defaultEntitlements } from './entitlement';
23+
import { mergeDeep } from 'shared/utils/highbar';
2624

27-
import type {
25+
import {
2826
Auth2faType,
2927
AuthProvider,
3028
AuthType,
3129
PreferredMfaType,
3230
PrimaryAuthType,
3331
} from 'shared/services';
3432

33+
import { KubeResourceKind } from 'teleport/services/kube/types';
34+
35+
import { defaultEntitlements } from './entitlement';
36+
import generateResourcePath from './generateResourcePath';
37+
3538
import type { SortType } from 'teleport/services/agents';
3639
import type { RecordingType } from 'teleport/services/recordings';
37-
import type { WebauthnAssertionResponse } from './services/auth';
38-
import type { PluginKind, Regions } from './services/integrations';
40+
import type { WebauthnAssertionResponse } from 'teleport/services/auth';
41+
import type { PluginKind, Regions } from 'teleport/services/integrations';
3942
import type { ParticipantMode } from 'teleport/services/session';
40-
import type { YamlSupportedResourceKind } from './services/yaml/types';
41-
import type { KubeResourceKind } from './services/kube/types';
43+
import type { YamlSupportedResourceKind } from 'teleport/services/yaml/types';
4244

4345
const cfg = {
4446
/** @deprecated Use cfg.edition instead. */
@@ -250,6 +252,7 @@ const cfg = {
250252
ttyPlaybackWsAddr:
251253
'wss://:fqdn/v1/webapi/sites/:clusterId/ttyplayback/:sid?access_token=:token', // TODO(zmb3): get token out of URL
252254
activeAndPendingSessionsPath: '/v1/webapi/sites/:clusterId/sessions',
255+
sessionDurationPath: '/v1/webapi/sites/:clusterId/sessionlength/:sid',
253256

254257
// TODO(zmb3): remove this when Assist is no longer using it
255258
sshPlaybackPrefix: '/v1/webapi/sites/:clusterId/sessions/:sid', // prefix because this is eventually concatenated with "/stream" or "/events"
@@ -747,6 +750,10 @@ const cfg = {
747750
return generatePath(cfg.api.activeAndPendingSessionsPath, { clusterId });
748751
},
749752

753+
getSessionDurationUrl(clusterId: string, sid: string) {
754+
return generatePath(cfg.api.sessionDurationPath, { clusterId, sid });
755+
},
756+
750757
getUnifiedResourcesUrl(clusterId: string, params: UrlResourcesParams) {
751758
return generateResourcePath(cfg.api.unifiedResourcesPath, {
752759
clusterId,

web/packages/teleport/src/services/mfa/types.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
*/
1818

1919
import { WebauthnAssertionResponse } from '../auth';
20-
import { DeviceUsage } from '../auth/types';
21-
import { CreateNewHardwareDeviceRequest } from '../auth/types';
20+
import { CreateNewHardwareDeviceRequest, DeviceUsage } from '../auth/types';
2221

2322
export interface MfaDevice {
2423
id: string;

web/packages/teleport/src/services/recordings/recordings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ export default class RecordingsService {
4545
return { recordings: events.map(makeRecording), startKey: json.startKey };
4646
});
4747
}
48+
49+
fetchRecordingDuration(
50+
clusterId: string,
51+
sessionId: string
52+
): Promise<{ durationMs: number }> {
53+
return api.get(cfg.getSessionDurationUrl(clusterId, sessionId));
54+
}
4855
}

0 commit comments

Comments
 (0)