Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v15] Fix web session playback when a duration is not provided #50462

Merged
merged 1 commit into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5260,19 +5260,6 @@ func findActiveDatabases(key *Key) ([]tlsca.RouteToDatabase, error) {
return databases, 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(
Expand Down
75 changes: 0 additions & 75 deletions lib/client/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"math"
"os"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/gravitational/trace"
Expand All @@ -42,9 +41,7 @@ import (
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/observability/tracing"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/utils"
)

Expand Down Expand Up @@ -899,78 +896,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) {
Expand Down
1 change: 1 addition & 0 deletions lib/events/auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@ func (l *AuditLog) StreamSessionEvents(ctx context.Context, sessionID session.ID
}

protoReader := NewProtoReader(rawSession)
defer protoReader.Close()

for {
if ctx.Err() != nil {
Expand Down
1 change: 1 addition & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,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))

// DELETE in 16(zmb3): v15+ web UIs use new streaming 'ttyplayback' endpoint
h.GET("/webapi/sites/:site/sessions/:sid/events", h.WithClusterAuth(h.siteSessionEventsGet)) // get recorded session's timing information (from events)
Expand Down
39 changes: 39 additions & 0 deletions lib/web/tty_playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,45 @@ 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
}
}
}
}

func (h *Handler) ttyPlaybackHandle(
w http.ResponseWriter,
r *http.Request,
Expand Down
70 changes: 57 additions & 13 deletions web/packages/teleport/src/Player/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import styled from 'styled-components';
import React, { useCallback, useEffect } from 'react';

import { Flex, Box } from 'design';
import styled from 'styled-components';

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 { getUrlParameter } from 'teleport/services/history';

import { RecordingType } from 'teleport/services/recordings';

import useTeleport from 'teleport/useTeleport';

import ActionBar from './ActionBar';
import { DesktopPlayer } from './DesktopPlayer';
import SshPlayer from './SshPlayer';
Expand All @@ -39,19 +41,44 @@ import Tabs, { TabItem } from './PlayerTabs';
const validRecordingTypes = ['ssh', 'k8s', 'desktop'];

export function Player() {
const ctx = useTeleport();
const { sid, clusterId } = useParams<UrlPlayerParams>();
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(
() => ctx.recordingsService.fetchRecordingDuration(clusterId, sid),
[ctx.recordingsService, clusterId, sid]
)
);

const validRecordingType = validRecordingTypes.includes(recordingType);
const validDurationMs = Number.isInteger(durationMs) && durationMs > 0;
const durationMs = Number(getUrlParameter('durationMs', search));
const shouldFetchSessionDuration =
validRecordingType && (!Number.isInteger(durationMs) || durationMs <= 0);

useEffect(() => {
if (shouldFetchSessionDuration) {
fetchDuration();
}
}, [fetchDuration, shouldFetchSessionDuration]);

document.title = `Play ${sid} • ${clusterId}`;
const combinedAttempt = shouldFetchSessionDuration
? fetchDurationAttempt
: makeSuccessAttempt({ durationMs });

function onLogout() {
session.logout();
Expand All @@ -70,13 +97,25 @@ export function Player() {
);
}

if (!validDurationMs) {
if (
combinedAttempt.status === '' ||
combinedAttempt.status === 'processing'
) {
return (
<StyledPlayer>
<Box textAlign="center" mx={10} mt={5}>
<Indicator />
</Box>
</StyledPlayer>
);
}
if (combinedAttempt.status === 'error') {
return (
<StyledPlayer>
<Box textAlign="center" mx={10} mt={5}>
<Danger mb={0}>
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.
</Danger>
</Box>
</StyledPlayer>
Expand All @@ -102,15 +141,20 @@ export function Player() {
<DesktopPlayer
sid={sid}
clusterId={clusterId}
durationMs={durationMs}
durationMs={combinedAttempt.data.durationMs}
/>
) : (
<SshPlayer sid={sid} clusterId={clusterId} durationMs={durationMs} />
<SshPlayer
sid={sid}
clusterId={clusterId}
durationMs={combinedAttempt.data.durationMs}
/>
)}
</Flex>
</StyledPlayer>
);
}

const StyledPlayer = styled.div`
display: flex;
height: 100%;
Expand Down
15 changes: 10 additions & 5 deletions web/packages/teleport/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@
import { generatePath } from 'react-router';
import { mergeDeep } from 'shared/utils/highbar';

import generateResourcePath from './generateResourcePath';

import type {
import {
Auth2faType,
AuthProvider,
AuthType,
PreferredMfaType,
PrimaryAuthType,
} from 'shared/services';

import generateResourcePath from './generateResourcePath';

import type { SortType } from 'teleport/services/agents';
import type { RecordingType } from 'teleport/services/recordings';
import type { WebauthnAssertionResponse } from './services/auth';
import type { Regions } from './services/integrations';
import type { WebauthnAssertionResponse } from 'teleport/services/auth';
import type { Regions } from 'teleport/services/integrations';
import type { ParticipantMode } from 'teleport/services/session';
import type { YamlSupportedResourceKind } from './services/yaml/types';

Expand Down Expand Up @@ -236,6 +236,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',

// TODO(zmb3): remove this for v15
sshPlaybackPrefix: '/v1/webapi/sites/:clusterId/sessions/:sid', // prefix because this is eventually concatenated with "/stream" or "/events"
Expand Down Expand Up @@ -701,6 +702,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,
Expand Down
3 changes: 1 addition & 2 deletions web/packages/teleport/src/services/mfa/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
*/

import { WebauthnAssertionResponse } from '../auth';
import { DeviceUsage } from '../auth/types';
import { CreateNewHardwareDeviceRequest } from '../auth/types';
import { CreateNewHardwareDeviceRequest, DeviceUsage } from '../auth/types';

export interface MfaDevice {
id: string;
Expand Down
7 changes: 7 additions & 0 deletions web/packages/teleport/src/services/recordings/recordings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ export default class RecordingsService {
return { recordings: events.map(makeRecording), startKey: json.startKey };
});
}

fetchRecordingDuration(
clusterId: string,
sessionId: string
): Promise<{ durationMs: number }> {
return api.get(cfg.getSessionDurationUrl(clusterId, sessionId));
}
}
Loading