mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
Refactor recordingmetadata service to be able to support different session types (#64260)
* Refactor recordingmetadata service to be able to support different session types * Do not capture thumbnails on resize, correctly set thumbnail time/interval * Fix inactivity offsets, improve the test * Improve the session join event test * Update ProcessSessionRecording in test * Fix test * Satisfy sloglint * Fix tests
This commit is contained in:
@@ -346,7 +346,7 @@ func TestSessionCompleter(t *testing.T) {
|
||||
|
||||
metadataProvider := recordingmetadata.NewProvider()
|
||||
recorderMetadata := &fakeRecordingMetadata{}
|
||||
recorderMetadata.On("ProcessSessionRecording", mock.Anything, sessionID, mock.Anything).
|
||||
recorderMetadata.On("ProcessSessionRecording", mock.Anything, sessionID, mock.Anything, mock.Anything).
|
||||
Return(nil).Once()
|
||||
metadataProvider.SetService(recorderMetadata)
|
||||
|
||||
@@ -387,8 +387,8 @@ type fakeRecordingMetadata struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, duration)
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, sessionType, duration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,6 @@ type noopRecordingMetadata struct{}
|
||||
|
||||
// ProcessSessionRecording is a no-op implementation of the
|
||||
// [Service.ProcessSessionRecording] method.
|
||||
func (n noopRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
|
||||
func (n noopRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType SessionType, duration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protodelim"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
|
||||
)
|
||||
|
||||
// recordingProcessor consumes audit events from a session recording and produces metadata and a representative
|
||||
// thumbnail. Implementations are specific to a session type (e.g. TTY for now, desktop and database in the future).
|
||||
type recordingProcessor interface {
|
||||
// handleEvent processes a single audit event and updates the processor's internal state. Returning an error aborts
|
||||
// metadata generation for the recording.
|
||||
handleEvent(event apievents.AuditEvent) error
|
||||
// collect finalizes the metadata and returns the session recording metadata along with the chosen thumbnail.
|
||||
// It should be called after all events have been processed.
|
||||
collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail)
|
||||
}
|
||||
|
||||
func newRecordingProcessor(writer io.WriteCloser, logger *slog.Logger, sessionType recordingmetadata.SessionType, duration time.Duration) recordingProcessor {
|
||||
base := baseRecordingProcessor{
|
||||
metadata: &pb.SessionRecordingMetadata{},
|
||||
writer: writer,
|
||||
logger: logger,
|
||||
thumbnailInterval: calculateThumbnailInterval(duration, maxThumbnails),
|
||||
thumbnailTime: getRandomThumbnailTime(duration),
|
||||
}
|
||||
|
||||
if sessionType == recordingmetadata.SessionTypeTTY {
|
||||
return newTTYRecordingProcessor(base)
|
||||
}
|
||||
|
||||
return &noopRecordingProcessor{}
|
||||
}
|
||||
|
||||
// baseRecordingProcessor holds the shared state and helpers used by all session-type-specific processors, such as
|
||||
// the writer for streaming thumbnail frames and the logic for selecting the best representative thumbnail.
|
||||
type baseRecordingProcessor struct {
|
||||
metadata *pb.SessionRecordingMetadata
|
||||
thumbnailGenerator thumbnailGenerator
|
||||
|
||||
lastEvent apievents.AuditEvent
|
||||
lastActivityTime time.Time
|
||||
startTime time.Time
|
||||
|
||||
thumbnailInterval time.Duration
|
||||
thumbnailTime time.Duration
|
||||
lastThumbnailTime time.Time
|
||||
thumbnail *pb.SessionRecordingThumbnail
|
||||
|
||||
writer io.WriteCloser
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (b *baseRecordingProcessor) captureThumbnailIfNeeded(eventTime time.Time) {
|
||||
if eventTime.Sub(b.lastThumbnailTime) < b.thumbnailInterval {
|
||||
return
|
||||
}
|
||||
|
||||
b.lastThumbnailTime = eventTime
|
||||
|
||||
thumbnail := b.thumbnailGenerator.produceThumbnail()
|
||||
thumbnail.StartOffset = durationpb.New(eventTime.Sub(b.startTime))
|
||||
thumbnail.EndOffset = durationpb.New(eventTime.Add(b.thumbnailInterval).Add(-1 * time.Millisecond).Sub(b.startTime))
|
||||
|
||||
if _, err := protodelim.MarshalTo(b.writer, thumbnail); err != nil {
|
||||
// log the error but continue processing other thumbnails and the session metadata (metadata is more important)
|
||||
b.logger.WarnContext(context.Background(), "Failed to marshal thumbnail entry", "error", err)
|
||||
}
|
||||
|
||||
if b.thumbnail == nil {
|
||||
b.thumbnail = thumbnail
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
previousDiff := math.Abs(float64(b.thumbnailTime - b.thumbnail.StartOffset.AsDuration()))
|
||||
diff := math.Abs(float64(b.thumbnailTime - eventTime.Sub(b.startTime)))
|
||||
|
||||
if diff < previousDiff {
|
||||
// this thumbnail is closer to the ideal thumbnail time, use it instead
|
||||
b.thumbnail = thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
type noopRecordingProcessor struct{}
|
||||
|
||||
func (n *noopRecordingProcessor) handleEvent(event apievents.AuditEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopRecordingProcessor) collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -24,27 +24,22 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/hinshun/vt10x"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"google.golang.org/protobuf/encoding/protodelim"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/auth/recordingencryption"
|
||||
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/player"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
"github.com/gravitational/teleport/lib/terminal"
|
||||
)
|
||||
|
||||
// UploadHandler uploads session recording metadata and thumbnails.
|
||||
@@ -111,7 +106,13 @@ func NewRecordingMetadataService(cfg RecordingMetadataServiceConfig) (*Recording
|
||||
|
||||
// ProcessSessionRecording processes the session recording associated with the provided session ID.
|
||||
// It streams session events, generates metadata, and uploads thumbnails and metadata.
|
||||
func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
|
||||
func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
|
||||
if sessionType != recordingmetadata.SessionTypeTTY {
|
||||
// Currently only TTY sessions (SSH + Kubernetes) are supported, so avoid doing any unnecessary work if the session
|
||||
// is a different type.
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionsPendingMetric.Inc()
|
||||
|
||||
if err := s.concurrencyLimiter.Acquire(ctx, 1); err != nil {
|
||||
@@ -128,32 +129,6 @@ func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context,
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
evts, errors := s.streamer.StreamSessionEvents(ctx, sessionID, 0)
|
||||
|
||||
var startTime time.Time
|
||||
var lastEvent apievents.AuditEvent
|
||||
var lastActivityTime time.Time
|
||||
var lastThumbnailTime time.Time
|
||||
|
||||
activeUsers := make(map[string]time.Duration)
|
||||
|
||||
vt := vt10x.New()
|
||||
|
||||
metadata := &pb.SessionRecordingMetadata{}
|
||||
|
||||
addInactivityEvent := func(start, end time.Time) {
|
||||
inactivityStart := durationpb.New(start.Sub(startTime))
|
||||
inactivityEnd := durationpb.New(end.Sub(startTime))
|
||||
|
||||
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: inactivityStart,
|
||||
EndOffset: inactivityEnd,
|
||||
Event: &pb.SessionRecordingEvent_Inactivity{
|
||||
Inactivity: &pb.SessionRecordingInactivityEvent{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// will either finish the upload or cancel it if exited early
|
||||
var finish sync.Once
|
||||
|
||||
@@ -170,52 +145,8 @@ func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context,
|
||||
})
|
||||
}()
|
||||
|
||||
interval := calculateThumbnailInterval(duration, maxThumbnails)
|
||||
thumbnailTime := getRandomThumbnailTime(duration)
|
||||
|
||||
// the thumbnail to upload for the session
|
||||
var recordingThumbnail *pb.SessionRecordingThumbnail
|
||||
|
||||
recordThumbnail := func(start time.Time) {
|
||||
cols, rows := vt.Size()
|
||||
cursor := vt.Cursor()
|
||||
|
||||
startOffset := start.Sub(startTime)
|
||||
endOffset := start.Add(interval).Add(-1 * time.Millisecond).Sub(startTime)
|
||||
|
||||
thumbnail := &pb.SessionRecordingThumbnail{
|
||||
Svg: terminal.VtToSvg(vt),
|
||||
Cols: int32(cols),
|
||||
Rows: int32(rows),
|
||||
CursorX: int32(cursor.X),
|
||||
CursorY: int32(cursor.Y),
|
||||
CursorVisible: vt.CursorVisible(),
|
||||
StartOffset: durationpb.New(startOffset),
|
||||
EndOffset: durationpb.New(endOffset),
|
||||
}
|
||||
|
||||
if _, err := protodelim.MarshalTo(w, thumbnail); err != nil {
|
||||
// log the error but continue processing other thumbnails and the session metadata (metadata is more important)
|
||||
s.logger.WarnContext(ctx, "Failed to marshal thumbnail entry",
|
||||
"session_id", sessionID, "error", err)
|
||||
}
|
||||
|
||||
if recordingThumbnail == nil {
|
||||
recordingThumbnail = thumbnail
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
previousDiff := math.Abs(float64(thumbnailTime - recordingThumbnail.StartOffset.AsDuration()))
|
||||
diff := math.Abs(float64(thumbnailTime - startOffset))
|
||||
|
||||
if diff < previousDiff {
|
||||
// this thumbnail is closer to the ideal thumbnail time, use it instead
|
||||
recordingThumbnail = thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
var hasSeenPrintEvent bool
|
||||
processor := newRecordingProcessor(w, s.logger.With("session_id", sessionID), sessionType, duration)
|
||||
evts, errors := s.streamer.StreamSessionEvents(ctx, sessionID, 0)
|
||||
|
||||
loop:
|
||||
for {
|
||||
@@ -225,115 +156,8 @@ loop:
|
||||
break loop
|
||||
}
|
||||
|
||||
lastEvent = evt
|
||||
|
||||
switch e := evt.(type) {
|
||||
case *apievents.DatabaseSessionStart, *apievents.WindowsDesktopSessionStart:
|
||||
// Unsupported session recording types
|
||||
return nil
|
||||
|
||||
case *apievents.Resize:
|
||||
size, err := session.UnmarshalTerminalParams(e.TerminalSize)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "parsing terminal size %q for session %v", e.TerminalSize, sessionID)
|
||||
}
|
||||
|
||||
// if we haven't seen a print event yet, update the starting size to the latest resize
|
||||
// this handles cases where the initial terminal size is not 80x24 and is resized immediately
|
||||
// before any output is printed
|
||||
if !hasSeenPrintEvent {
|
||||
metadata.StartCols = int32(size.W)
|
||||
metadata.StartRows = int32(size.H)
|
||||
}
|
||||
|
||||
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(e.Time.Sub(startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Resize{
|
||||
Resize: &pb.SessionRecordingResizeEvent{
|
||||
Cols: int32(size.W),
|
||||
Rows: int32(size.H),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vt.Resize(size.W, size.H)
|
||||
|
||||
case *apievents.SessionEnd:
|
||||
if !lastActivityTime.IsZero() && e.Time.Sub(lastActivityTime) > inactivityThreshold {
|
||||
addInactivityEvent(lastActivityTime, e.Time)
|
||||
}
|
||||
|
||||
if e.Time.Sub(lastThumbnailTime) >= interval {
|
||||
lastThumbnailTime = e.Time
|
||||
recordThumbnail(e.Time)
|
||||
}
|
||||
|
||||
case *apievents.SessionJoin:
|
||||
activeUsers[e.User] = e.Time.Sub(startTime)
|
||||
|
||||
case *apievents.SessionLeave:
|
||||
if joinTime, ok := activeUsers[e.User]; ok {
|
||||
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(joinTime),
|
||||
EndOffset: durationpb.New(e.Time.Sub(startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Join{
|
||||
Join: &pb.SessionRecordingJoinEvent{
|
||||
User: e.User,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
delete(activeUsers, e.User)
|
||||
}
|
||||
|
||||
case *apievents.SessionPrint:
|
||||
// mark that we've seen the first print event so we don't update the starting size anymore
|
||||
if !hasSeenPrintEvent {
|
||||
hasSeenPrintEvent = true
|
||||
}
|
||||
|
||||
if !lastActivityTime.IsZero() && e.Time.Sub(lastActivityTime) > inactivityThreshold {
|
||||
addInactivityEvent(lastActivityTime, e.Time)
|
||||
}
|
||||
|
||||
if _, err := vt.Write(e.Data); err != nil {
|
||||
return trace.Errorf("writing data to terminal: %w", err)
|
||||
}
|
||||
|
||||
if e.Time.Sub(lastThumbnailTime) >= interval {
|
||||
lastThumbnailTime = e.Time
|
||||
recordThumbnail(e.Time)
|
||||
}
|
||||
|
||||
lastActivityTime = e.Time
|
||||
|
||||
case *apievents.SessionStart:
|
||||
lastActivityTime = e.Time
|
||||
startTime = e.Time
|
||||
|
||||
size, err := session.UnmarshalTerminalParams(e.TerminalSize)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "parsing terminal size %q for session %v", e.TerminalSize, sessionID)
|
||||
}
|
||||
|
||||
// store the initial terminal size, this is typically 80:24 and is resized immediately
|
||||
metadata.StartCols = int32(size.W)
|
||||
metadata.StartRows = int32(size.H)
|
||||
|
||||
metadata.ClusterName = e.ClusterName
|
||||
metadata.User = e.User
|
||||
|
||||
switch e.Protocol {
|
||||
case events.EventProtocolSSH:
|
||||
metadata.ResourceName = e.ServerHostname
|
||||
metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_SSH
|
||||
|
||||
case events.EventProtocolKube:
|
||||
metadata.ResourceName = e.KubernetesCluster
|
||||
metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_KUBERNETES
|
||||
}
|
||||
|
||||
vt.Resize(size.W, size.H)
|
||||
if err := processor.handleEvent(evt); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
case err := <-uploadErrs:
|
||||
@@ -349,34 +173,18 @@ loop:
|
||||
}
|
||||
}
|
||||
|
||||
if lastEvent == nil {
|
||||
metadata, thumbnail := processor.collect()
|
||||
if metadata == nil {
|
||||
return trace.NotFound("no events found for session %v", sessionID)
|
||||
}
|
||||
|
||||
if recordingThumbnail != nil {
|
||||
if err := s.uploadThumbnail(ctx, sessionID, recordingThumbnail); err != nil {
|
||||
if thumbnail != nil {
|
||||
if err := s.uploadThumbnail(ctx, sessionID, thumbnail); err != nil {
|
||||
s.logger.WarnContext(ctx, "Failed to upload thumbnail",
|
||||
"session_id", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Finish off any remaining activity events
|
||||
for user, userStartOffset := range activeUsers {
|
||||
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(userStartOffset),
|
||||
EndOffset: durationpb.New(lastEvent.GetTime().Sub(startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Join{
|
||||
Join: &pb.SessionRecordingJoinEvent{
|
||||
User: user,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
metadata.Duration = durationpb.New(lastEvent.GetTime().Sub(startTime))
|
||||
metadata.StartTime = timestamppb.New(startTime)
|
||||
metadata.EndTime = timestamppb.New(lastEvent.GetTime())
|
||||
|
||||
if _, err := protodelim.MarshalTo(w, metadata); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -28,169 +26,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
)
|
||||
|
||||
func TestProcessSessionRecording(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
events []apievents.AuditEvent
|
||||
expectError bool
|
||||
expectedThumbnails func(t *testing.T, thumbnailData []byte)
|
||||
expectedMetadata func(t *testing.T, metadata *pb.SessionRecordingMetadata)
|
||||
expectedFrames func(t *testing.T, frames []*pb.SessionRecordingThumbnail)
|
||||
}{
|
||||
{
|
||||
name: "basic session with print events",
|
||||
events: generateBasicSession(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
require.NotNil(t, metadata.Duration)
|
||||
require.Equal(t, int32(80), metadata.StartCols)
|
||||
require.Equal(t, int32(24), metadata.StartRows)
|
||||
},
|
||||
expectedFrames: func(t *testing.T, frames []*pb.SessionRecordingThumbnail) {
|
||||
require.NotEmpty(t, frames)
|
||||
},
|
||||
expectedThumbnails: func(t *testing.T, thumbnailData []byte) {
|
||||
var thumbnail pb.SessionRecordingThumbnail
|
||||
err := proto.Unmarshal(thumbnailData, &thumbnail)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, thumbnail.Svg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "basic session with print events with immediate resize after start",
|
||||
events: generateBasicSessionWithImmediateResize(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
require.NotNil(t, metadata.Duration)
|
||||
require.Equal(t, int32(100), metadata.StartCols)
|
||||
require.Equal(t, int32(30), metadata.StartRows)
|
||||
},
|
||||
expectedFrames: func(t *testing.T, frames []*pb.SessionRecordingThumbnail) {
|
||||
require.NotEmpty(t, frames)
|
||||
},
|
||||
expectedThumbnails: func(t *testing.T, thumbnailData []byte) {
|
||||
var thumbnail pb.SessionRecordingThumbnail
|
||||
err := proto.Unmarshal(thumbnailData, &thumbnail)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, thumbnail.Svg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session with resize events",
|
||||
events: generateSessionWithResize(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
var hasResize bool
|
||||
for _, event := range metadata.Events {
|
||||
if resize := event.GetResize(); resize != nil {
|
||||
hasResize = true
|
||||
require.Equal(t, int32(120), resize.Cols)
|
||||
require.Equal(t, int32(40), resize.Rows)
|
||||
}
|
||||
}
|
||||
require.True(t, hasResize, "expected resize event")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session with inactivity periods",
|
||||
events: generateSessionWithInactivity(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
var hasInactivity bool
|
||||
for _, event := range metadata.Events {
|
||||
if event.GetInactivity() != nil {
|
||||
hasInactivity = true
|
||||
require.NotNil(t, event.StartOffset)
|
||||
require.NotNil(t, event.EndOffset)
|
||||
}
|
||||
}
|
||||
require.True(t, hasInactivity, "expected inactivity event")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session with join and leave events",
|
||||
events: generateSessionWithJoinLeave(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
joinUsers := make(map[string]bool)
|
||||
for _, event := range metadata.Events {
|
||||
if join := event.GetJoin(); join != nil {
|
||||
joinUsers[join.User] = true
|
||||
}
|
||||
}
|
||||
require.True(t, joinUsers["alice"], "expected alice join event")
|
||||
require.True(t, joinUsers["bob"], "expected bob join event")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sessionID := session.NewID()
|
||||
|
||||
streamer := &mockStreamer{
|
||||
events: tt.events,
|
||||
errorOnEvent: -1,
|
||||
}
|
||||
uploadHandler := newMockUploadHandler()
|
||||
|
||||
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
|
||||
Streamer: streamer,
|
||||
UploadHandler: uploadHandler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lastEventTime := tt.events[len(tt.events)-1].GetTime()
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, lastEventTime.Sub(startTime))
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler.mu.Lock()
|
||||
metadataData, ok := uploadHandler.metadata[string(sessionID)]
|
||||
uploadHandler.mu.Unlock()
|
||||
require.True(t, ok, "metadata should be uploaded")
|
||||
|
||||
metadata, frames, err := unmarshalMetadata(metadataData)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.expectedMetadata != nil {
|
||||
tt.expectedMetadata(t, metadata)
|
||||
}
|
||||
|
||||
if tt.expectedFrames != nil {
|
||||
tt.expectedFrames(t, frames)
|
||||
}
|
||||
|
||||
uploadHandler.mu.Lock()
|
||||
thumbnailData, ok := uploadHandler.thumbnails[string(sessionID)]
|
||||
uploadHandler.mu.Unlock()
|
||||
if ok && tt.expectedThumbnails != nil {
|
||||
tt.expectedThumbnails(t, thumbnailData)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSessionRecording_StreamError(t *testing.T) {
|
||||
sessionID := session.NewID()
|
||||
|
||||
@@ -205,7 +48,7 @@ func TestProcessSessionRecording_StreamError(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stream error")
|
||||
}
|
||||
@@ -227,7 +70,7 @@ func TestProcessSessionRecording_UploadError(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upload failed")
|
||||
}
|
||||
@@ -250,7 +93,7 @@ func TestProcessSessionRecording_ContextCancellation(t *testing.T) {
|
||||
|
||||
processDone := make(chan error, 1)
|
||||
go func() {
|
||||
processDone <- service.ProcessSessionRecording(ctx, sessionID, 10*time.Second)
|
||||
processDone <- service.ProcessSessionRecording(ctx, sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
|
||||
}()
|
||||
|
||||
streamer.WaitUntilBlocking()
|
||||
@@ -267,104 +110,9 @@ func TestProcessSessionRecording_ContextCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSessionRecording_UnsupportedSessionTypes(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
sessionID := session.NewID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
events []apievents.AuditEvent
|
||||
}{
|
||||
{
|
||||
name: "database session",
|
||||
events: []apievents.AuditEvent{
|
||||
&apievents.DatabaseSessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "db.session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows desktop session",
|
||||
events: []apievents.AuditEvent{
|
||||
&apievents.WindowsDesktopSessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "windows.desktop.session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
streamer := &mockStreamer{
|
||||
events: tt.events,
|
||||
errorOnEvent: -1,
|
||||
}
|
||||
uploadHandler := newMockUploadHandler()
|
||||
|
||||
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
|
||||
Streamer: streamer,
|
||||
UploadHandler: uploadHandler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler.mu.Lock()
|
||||
metadataLen := len(uploadHandler.metadata)
|
||||
thumbnailsLen := len(uploadHandler.thumbnails)
|
||||
uploadHandler.mu.Unlock()
|
||||
|
||||
require.Equal(t, 0, metadataLen, "metadata should be empty")
|
||||
require.Equal(t, 0, thumbnailsLen, "thumbnails should be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
sessionID := session.NewID()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(1 * time.Second),
|
||||
},
|
||||
Data: []byte("Hello\n"),
|
||||
},
|
||||
&apievents.Resize{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "resize",
|
||||
Time: startTime.Add(2 * time.Second),
|
||||
},
|
||||
TerminalSize: "invalid:terminal:size",
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(10 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
streamer := &mockStreamer{
|
||||
events: events,
|
||||
errorOnEvent: -1,
|
||||
}
|
||||
uploadHandler := newMockUploadHandler()
|
||||
@@ -375,7 +123,43 @@ func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeUnspecified, 10*time.Second)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler.mu.Lock()
|
||||
metadataLen := len(uploadHandler.metadata)
|
||||
thumbnailsLen := len(uploadHandler.thumbnails)
|
||||
uploadHandler.mu.Unlock()
|
||||
|
||||
require.Equal(t, 0, metadataLen, "metadata should be empty")
|
||||
require.Equal(t, 0, thumbnailsLen, "thumbnails should be empty")
|
||||
}
|
||||
|
||||
func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
sessionID := session.NewID()
|
||||
|
||||
evts := []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n"),
|
||||
resizeEvent(startTime.Add(2*time.Second), "invalid:terminal:size"),
|
||||
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
|
||||
}
|
||||
|
||||
streamer := &mockStreamer{
|
||||
events: evts,
|
||||
errorOnEvent: -1,
|
||||
}
|
||||
uploadHandler := newMockUploadHandler()
|
||||
|
||||
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
|
||||
Streamer: streamer,
|
||||
UploadHandler: uploadHandler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parsing terminal size")
|
||||
@@ -390,40 +174,15 @@ func TestProcessSessionRecording_UploadFailsDuringProcessing(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
sessionID := session.NewID()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(1 * time.Second),
|
||||
},
|
||||
Data: []byte("Hello\n"),
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(2 * time.Second),
|
||||
},
|
||||
Data: []byte("World\n"),
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(10 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(10 * time.Second),
|
||||
},
|
||||
evts := []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n"),
|
||||
sessionPrintEvent(startTime.Add(2*time.Second), "World\n"),
|
||||
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
|
||||
}
|
||||
|
||||
streamer := &mockStreamer{
|
||||
events: events,
|
||||
events: evts,
|
||||
errorOnEvent: -1,
|
||||
}
|
||||
|
||||
@@ -437,12 +196,55 @@ func TestProcessSessionRecording_UploadFailsDuringProcessing(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
|
||||
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "simulated upload failure")
|
||||
}
|
||||
|
||||
func sessionStartEvent(t time.Time, size string) *apievents.SessionStart {
|
||||
return &apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{Type: "session.start", Time: t},
|
||||
TerminalSize: size,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionPrintEvent(t time.Time, data string) *apievents.SessionPrint {
|
||||
return &apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{Type: "print", Time: t},
|
||||
Data: []byte(data),
|
||||
}
|
||||
}
|
||||
|
||||
func resizeEvent(t time.Time, size string) *apievents.Resize {
|
||||
return &apievents.Resize{
|
||||
Metadata: apievents.Metadata{Type: "resize", Time: t},
|
||||
TerminalSize: size,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionEndEvent(startTime time.Time, endTime time.Time) *apievents.SessionEnd {
|
||||
return &apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{Type: "session.end", Time: endTime},
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionJoinEvent(t time.Time, user string) *apievents.SessionJoin {
|
||||
return &apievents.SessionJoin{
|
||||
Metadata: apievents.Metadata{Type: "session.join", Time: t},
|
||||
UserMetadata: apievents.UserMetadata{User: user},
|
||||
}
|
||||
}
|
||||
|
||||
func sessionLeaveEvent(t time.Time, user string) *apievents.SessionLeave {
|
||||
return &apievents.SessionLeave{
|
||||
Metadata: apievents.Metadata{Type: "session.leave", Time: t},
|
||||
UserMetadata: apievents.UserMetadata{User: user},
|
||||
}
|
||||
}
|
||||
|
||||
// mockUploadHandlerFailAfterRead simulates an upload failure after reading some bytes
|
||||
type mockUploadHandlerFailAfterRead struct {
|
||||
failAfterBytes int
|
||||
@@ -488,224 +290,6 @@ func (m *mockUploadHandlerFailAfterRead) UploadThumbnail(ctx context.Context, se
|
||||
return "thumbnail/success", nil
|
||||
}
|
||||
|
||||
func generateBasicSessionWithImmediateResize(startTime time.Time) []apievents.AuditEvent {
|
||||
events := generateBasicSession(startTime)
|
||||
|
||||
// Insert a resize event immediately after session start
|
||||
resizeEvent := &apievents.Resize{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "resize",
|
||||
Time: startTime.Add(500 * time.Millisecond),
|
||||
},
|
||||
TerminalSize: "100:30",
|
||||
}
|
||||
|
||||
events = append([]apievents.AuditEvent{events[0], resizeEvent}, events[1:]...)
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func generateBasicSession(startTime time.Time) []apievents.AuditEvent {
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(1 * time.Second),
|
||||
},
|
||||
Data: []byte("Hello World\n"),
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(2 * time.Second),
|
||||
},
|
||||
Data: []byte("$ ls -la\n"),
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(10 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func generateSessionWithResize(startTime time.Time) []apievents.AuditEvent {
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(1 * time.Second),
|
||||
},
|
||||
Data: []byte("Initial output\n"),
|
||||
},
|
||||
&apievents.Resize{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "resize",
|
||||
Time: startTime.Add(2 * time.Second),
|
||||
},
|
||||
TerminalSize: "120:40",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(3 * time.Second),
|
||||
},
|
||||
Data: []byte("After resize\n"),
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(10 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func generateSessionWithInactivity(startTime time.Time) []apievents.AuditEvent {
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(1 * time.Second),
|
||||
},
|
||||
Data: []byte("Active\n"),
|
||||
},
|
||||
// 20 second gap
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(21 * time.Second),
|
||||
},
|
||||
Data: []byte("After inactivity\n"),
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(20 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(20 * time.Second),
|
||||
},
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func generateSessionWithJoinLeave(startTime time.Time) []apievents.AuditEvent {
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.start",
|
||||
Time: startTime,
|
||||
},
|
||||
TerminalSize: "80:24",
|
||||
},
|
||||
&apievents.SessionJoin{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.join",
|
||||
Time: startTime.Add(2 * time.Second),
|
||||
},
|
||||
UserMetadata: apievents.UserMetadata{
|
||||
User: "alice",
|
||||
},
|
||||
},
|
||||
&apievents.SessionPrint{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "print",
|
||||
Time: startTime.Add(3 * time.Second),
|
||||
},
|
||||
Data: []byte("Alice joined\n"),
|
||||
},
|
||||
&apievents.SessionJoin{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.join",
|
||||
Time: startTime.Add(4 * time.Second),
|
||||
},
|
||||
UserMetadata: apievents.UserMetadata{
|
||||
User: "bob",
|
||||
},
|
||||
},
|
||||
&apievents.SessionLeave{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.leave",
|
||||
Time: startTime.Add(6 * time.Second),
|
||||
},
|
||||
UserMetadata: apievents.UserMetadata{
|
||||
User: "alice",
|
||||
},
|
||||
},
|
||||
&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: "session.end",
|
||||
Time: startTime.Add(10 * time.Second),
|
||||
},
|
||||
StartTime: startTime,
|
||||
EndTime: startTime.Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func unmarshalMetadata(data []byte) (*pb.SessionRecordingMetadata, []*pb.SessionRecordingThumbnail, error) {
|
||||
reader := bufio.NewReader(bytes.NewReader(data))
|
||||
|
||||
var metadata *pb.SessionRecordingMetadata
|
||||
var frames []*pb.SessionRecordingThumbnail
|
||||
|
||||
for {
|
||||
msgBytes, err := readDelimitedMessage(reader)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var m pb.SessionRecordingMetadata
|
||||
if err := proto.Unmarshal(msgBytes, &m); err == nil {
|
||||
metadata = &m
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var f pb.SessionRecordingThumbnail
|
||||
if err := proto.Unmarshal(msgBytes, &f); err == nil {
|
||||
frames = append(frames, &f)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, nil, trace.BadParameter("failed to parse message as metadata or thumbnail")
|
||||
}
|
||||
|
||||
return metadata, frames, nil
|
||||
}
|
||||
|
||||
// mockStreamer implements player.Streamer for testing
|
||||
type mockStreamer struct {
|
||||
events []apievents.AuditEvent
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
)
|
||||
|
||||
// thumbnailGenerator receives events from a single session recording and uses
|
||||
// them to produce the preview frames and final thumbnail for the session.
|
||||
type thumbnailGenerator interface {
|
||||
// handleEvent receives an audit event from a recording and updates
|
||||
// the generator's internal state. If it returns an error then the
|
||||
// metadata generation process is aborted for this recording.
|
||||
handleEvent(event apievents.AuditEvent) error
|
||||
// produceThumbnail creates a thumbnail using the current state of the generator.
|
||||
produceThumbnail() *pb.SessionRecordingThumbnail
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
)
|
||||
|
||||
// ttyRecordingProcessor handles TTY-based sessions (SSH and Kubernetes). It tracks terminal state through a virtual
|
||||
// terminal emulator to generate SVG thumbnails, and records resize events, user join/leave activity, and inactivity periods.
|
||||
type ttyRecordingProcessor struct {
|
||||
baseRecordingProcessor
|
||||
activeUsers map[string]time.Duration
|
||||
hasSeenPrintEvent bool
|
||||
}
|
||||
|
||||
func newTTYRecordingProcessor(base baseRecordingProcessor) *ttyRecordingProcessor {
|
||||
base.thumbnailGenerator = newTTYThumbnailGenerator()
|
||||
|
||||
return &ttyRecordingProcessor{
|
||||
baseRecordingProcessor: base,
|
||||
activeUsers: make(map[string]time.Duration),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleEvent(evt apievents.AuditEvent) error {
|
||||
t.lastEvent = evt
|
||||
|
||||
switch e := evt.(type) {
|
||||
case *apievents.SessionStart:
|
||||
return t.handleSessionStart(e)
|
||||
|
||||
case *apievents.Resize:
|
||||
return t.handleResize(e)
|
||||
|
||||
case *apievents.SessionPrint:
|
||||
return t.handleSessionPrint(e)
|
||||
|
||||
case *apievents.SessionJoin:
|
||||
return t.handleSessionJoin(e)
|
||||
|
||||
case *apievents.SessionLeave:
|
||||
return t.handleSessionLeave(e)
|
||||
|
||||
case *apievents.SessionEnd:
|
||||
return t.handleSessionEnd(e)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleSessionStart(evt *apievents.SessionStart) error {
|
||||
t.lastActivityTime = evt.Time
|
||||
t.startTime = evt.Time
|
||||
|
||||
size, err := session.UnmarshalTerminalParams(evt.TerminalSize)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "parsing terminal size %q", evt.TerminalSize)
|
||||
}
|
||||
|
||||
// store the initial terminal size, this is typically 80:24 and is resized immediately
|
||||
t.metadata.StartCols = int32(size.W)
|
||||
t.metadata.StartRows = int32(size.H)
|
||||
|
||||
t.metadata.ClusterName = evt.ClusterName
|
||||
t.metadata.User = evt.User
|
||||
|
||||
switch evt.Protocol {
|
||||
case events.EventProtocolSSH:
|
||||
t.metadata.ResourceName = evt.ServerHostname
|
||||
t.metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_SSH
|
||||
|
||||
case events.EventProtocolKube:
|
||||
t.metadata.ResourceName = evt.KubernetesCluster
|
||||
t.metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_KUBERNETES
|
||||
}
|
||||
|
||||
return t.thumbnailGenerator.handleEvent(evt)
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleResize(evt *apievents.Resize) error {
|
||||
size, err := session.UnmarshalTerminalParams(evt.TerminalSize)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "parsing terminal size %q", evt.TerminalSize)
|
||||
}
|
||||
|
||||
// if we haven't seen a print event yet, update the starting size to the latest resize
|
||||
// this handles cases where the initial terminal size is not 80x24 and is resized immediately
|
||||
// before any output is printed
|
||||
if !t.hasSeenPrintEvent {
|
||||
t.metadata.StartCols = int32(size.W)
|
||||
t.metadata.StartRows = int32(size.H)
|
||||
}
|
||||
|
||||
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(evt.Time.Sub(t.startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Resize{
|
||||
Resize: &pb.SessionRecordingResizeEvent{
|
||||
Cols: int32(size.W),
|
||||
Rows: int32(size.H),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return t.thumbnailGenerator.handleEvent(evt)
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleSessionPrint(evt *apievents.SessionPrint) error {
|
||||
// mark that we've seen the first print event so we don't update the starting size anymore
|
||||
if !t.hasSeenPrintEvent {
|
||||
t.hasSeenPrintEvent = true
|
||||
}
|
||||
|
||||
if !t.lastActivityTime.IsZero() && evt.Time.Sub(t.lastActivityTime) > inactivityThreshold {
|
||||
t.addInactivityEvent(t.lastActivityTime, evt.Time)
|
||||
}
|
||||
|
||||
t.lastActivityTime = evt.Time
|
||||
|
||||
if err := t.thumbnailGenerator.handleEvent(evt); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
t.captureThumbnailIfNeeded(evt.Time)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleSessionJoin(evt *apievents.SessionJoin) error {
|
||||
t.activeUsers[evt.User] = evt.Time.Sub(t.startTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleSessionLeave(evt *apievents.SessionLeave) error {
|
||||
if joinTime, ok := t.activeUsers[evt.User]; ok {
|
||||
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(joinTime),
|
||||
EndOffset: durationpb.New(evt.Time.Sub(t.startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Join{
|
||||
Join: &pb.SessionRecordingJoinEvent{
|
||||
User: evt.User,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
delete(t.activeUsers, evt.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) handleSessionEnd(evt *apievents.SessionEnd) error {
|
||||
if !t.lastActivityTime.IsZero() && evt.Time.Sub(t.lastActivityTime) > inactivityThreshold {
|
||||
t.addInactivityEvent(t.lastActivityTime, evt.Time)
|
||||
}
|
||||
|
||||
t.captureThumbnailIfNeeded(evt.Time)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) addInactivityEvent(start, end time.Time) {
|
||||
inactivityStart := durationpb.New(start.Sub(t.startTime))
|
||||
inactivityEnd := durationpb.New(end.Sub(t.startTime))
|
||||
|
||||
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: inactivityStart,
|
||||
EndOffset: inactivityEnd,
|
||||
Event: &pb.SessionRecordingEvent_Inactivity{
|
||||
Inactivity: &pb.SessionRecordingInactivityEvent{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ttyRecordingProcessor) collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail) {
|
||||
// Finish off any remaining activity events
|
||||
for user, userStartOffset := range t.activeUsers {
|
||||
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
|
||||
StartOffset: durationpb.New(userStartOffset),
|
||||
EndOffset: durationpb.New(t.lastEvent.GetTime().Sub(t.startTime)),
|
||||
Event: &pb.SessionRecordingEvent_Join{
|
||||
Join: &pb.SessionRecordingJoinEvent{
|
||||
User: user,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
t.metadata.Duration = durationpb.New(t.lastEvent.GetTime().Sub(t.startTime))
|
||||
t.metadata.StartTime = timestamppb.New(t.startTime)
|
||||
t.metadata.EndTime = timestamppb.New(t.lastEvent.GetTime())
|
||||
|
||||
return t.metadata, t.thumbnail
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
|
||||
)
|
||||
|
||||
func newTestTTYProcessor(duration time.Duration) recordingProcessor {
|
||||
return newRecordingProcessor(&nopCloser{io.Discard}, slog.Default(), recordingmetadata.SessionTypeTTY, duration)
|
||||
}
|
||||
|
||||
func TestTTYRecordingProcessor(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
events []apievents.AuditEvent
|
||||
expectedMetadata func(t *testing.T, metadata *pb.SessionRecordingMetadata)
|
||||
expectedThumbnail func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail)
|
||||
}{
|
||||
{
|
||||
name: "basic session with print events",
|
||||
events: generateBasicSession(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
require.NotNil(t, metadata.Duration)
|
||||
require.Equal(t, int32(80), metadata.StartCols)
|
||||
require.Equal(t, int32(24), metadata.StartRows)
|
||||
},
|
||||
expectedThumbnail: func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail) {
|
||||
require.NotNil(t, thumbnail)
|
||||
require.NotEmpty(t, thumbnail.Svg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "immediate resize before print updates start size",
|
||||
events: generateBasicSessionWithImmediateResize(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
require.NotNil(t, metadata.Duration)
|
||||
require.Equal(t, int32(100), metadata.StartCols)
|
||||
require.Equal(t, int32(30), metadata.StartRows)
|
||||
},
|
||||
expectedThumbnail: func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail) {
|
||||
require.NotNil(t, thumbnail)
|
||||
require.NotEmpty(t, thumbnail.Svg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resize events are recorded in metadata",
|
||||
events: generateSessionWithResize(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
var hasResize bool
|
||||
for _, event := range metadata.Events {
|
||||
if resize := event.GetResize(); resize != nil {
|
||||
hasResize = true
|
||||
require.Equal(t, int32(120), resize.Cols)
|
||||
require.Equal(t, int32(40), resize.Rows)
|
||||
}
|
||||
}
|
||||
require.True(t, hasResize, "expected resize event")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactivity periods are detected",
|
||||
events: generateSessionWithInactivity(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
var inactivityCount int
|
||||
for _, event := range metadata.Events {
|
||||
if event.GetInactivity() != nil {
|
||||
inactivityCount++
|
||||
|
||||
require.Equal(t, 1*time.Second, event.StartOffset.AsDuration(), "inactivity should start at last activity time")
|
||||
require.Equal(t, 21*time.Second, event.EndOffset.AsDuration(), "inactivity should end when activity resumes")
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, inactivityCount, "expected exactly one inactivity event")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join and leave events are tracked",
|
||||
events: generateSessionWithJoinLeave(startTime),
|
||||
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
joins := make(map[string]*pb.SessionRecordingEvent)
|
||||
for _, event := range metadata.Events {
|
||||
if join := event.GetJoin(); join != nil {
|
||||
joins[join.User] = event
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, joins, 2, "expected join events for alice and bob")
|
||||
|
||||
require.Equal(t, 2*time.Second, joins["alice"].StartOffset.AsDuration())
|
||||
require.Equal(t, 6*time.Second, joins["alice"].EndOffset.AsDuration())
|
||||
|
||||
require.Equal(t, 4*time.Second, joins["bob"].StartOffset.AsDuration())
|
||||
require.Equal(t, 10*time.Second, joins["bob"].EndOffset.AsDuration())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lastEventTime := tt.events[len(tt.events)-1].GetTime()
|
||||
duration := lastEventTime.Sub(startTime)
|
||||
|
||||
processor := newTestTTYProcessor(duration)
|
||||
|
||||
for _, evt := range tt.events {
|
||||
require.NoError(t, processor.handleEvent(evt))
|
||||
}
|
||||
|
||||
metadata, thumbnail := processor.collect()
|
||||
|
||||
if tt.expectedMetadata != nil {
|
||||
tt.expectedMetadata(t, metadata)
|
||||
}
|
||||
|
||||
if tt.expectedThumbnail != nil {
|
||||
tt.expectedThumbnail(t, thumbnail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTYRecordingProcessor_MalformedResizeEvent(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
|
||||
processor := newTestTTYProcessor(10 * time.Second)
|
||||
|
||||
require.NoError(t, processor.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, processor.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n")))
|
||||
|
||||
err := processor.handleEvent(resizeEvent(startTime.Add(2*time.Second), "invalid:terminal:size"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parsing terminal size")
|
||||
}
|
||||
|
||||
func generateBasicSessionWithImmediateResize(startTime time.Time) []apievents.AuditEvent {
|
||||
events := generateBasicSession(startTime)
|
||||
|
||||
// Insert a resize event immediately after session start
|
||||
events = append([]apievents.AuditEvent{events[0], resizeEvent(startTime.Add(500*time.Millisecond), "100:30")}, events[1:]...)
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func generateBasicSession(startTime time.Time) []apievents.AuditEvent {
|
||||
return []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionPrintEvent(startTime.Add(1*time.Second), "Hello World\n"),
|
||||
sessionPrintEvent(startTime.Add(2*time.Second), "$ ls -la\n"),
|
||||
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
|
||||
}
|
||||
}
|
||||
|
||||
func generateSessionWithResize(startTime time.Time) []apievents.AuditEvent {
|
||||
return []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionPrintEvent(startTime.Add(1*time.Second), "Initial output\n"),
|
||||
resizeEvent(startTime.Add(2*time.Second), "120:40"),
|
||||
sessionPrintEvent(startTime.Add(3*time.Second), "After resize\n"),
|
||||
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
|
||||
}
|
||||
}
|
||||
|
||||
func generateSessionWithInactivity(startTime time.Time) []apievents.AuditEvent {
|
||||
return []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionPrintEvent(startTime.Add(1*time.Second), "Active\n"),
|
||||
// 20 second gap
|
||||
sessionPrintEvent(startTime.Add(21*time.Second), "After inactivity\n"),
|
||||
sessionEndEvent(startTime, startTime.Add(20*time.Second)),
|
||||
}
|
||||
}
|
||||
|
||||
func generateSessionWithJoinLeave(startTime time.Time) []apievents.AuditEvent {
|
||||
return []apievents.AuditEvent{
|
||||
sessionStartEvent(startTime, "80:24"),
|
||||
sessionJoinEvent(startTime.Add(2*time.Second), "alice"),
|
||||
sessionPrintEvent(startTime.Add(3*time.Second), "Alice joined\n"),
|
||||
sessionJoinEvent(startTime.Add(4*time.Second), "bob"),
|
||||
sessionLeaveEvent(startTime.Add(6*time.Second), "alice"),
|
||||
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/hinshun/vt10x"
|
||||
|
||||
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
"github.com/gravitational/teleport/lib/terminal"
|
||||
)
|
||||
|
||||
// ttyThumbnailGenerator is a thumbnail generator that produces thumbnails for a terminal session (i.e. SSH, kubernetes)
|
||||
// by maintaining an internal virtual terminal that it updates as it processes events from the session recording.
|
||||
type ttyThumbnailGenerator struct {
|
||||
vt vt10x.Terminal
|
||||
}
|
||||
|
||||
func newTTYThumbnailGenerator() *ttyThumbnailGenerator {
|
||||
return &ttyThumbnailGenerator{
|
||||
vt: vt10x.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) handleEvent(event apievents.AuditEvent) error {
|
||||
switch e := event.(type) {
|
||||
case *apievents.SessionStart:
|
||||
return t.handleSessionStart(e)
|
||||
|
||||
case *apievents.Resize:
|
||||
return t.handleResize(e)
|
||||
|
||||
case *apievents.SessionPrint:
|
||||
return t.handleSessionPrint(e)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) handleSessionStart(evt *apievents.SessionStart) error {
|
||||
return t.handleTerminalResize(evt.TerminalSize)
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) handleResize(evt *apievents.Resize) error {
|
||||
return t.handleTerminalResize(evt.TerminalSize)
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) handleSessionPrint(evt *apievents.SessionPrint) error {
|
||||
if _, err := t.vt.Write(evt.Data); err != nil {
|
||||
return trace.Errorf("writing data to terminal: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) handleTerminalResize(terminalSize string) error {
|
||||
size, err := session.UnmarshalTerminalParams(terminalSize)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "parsing terminal size %q", terminalSize)
|
||||
}
|
||||
|
||||
t.vt.Resize(size.W, size.H)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ttyThumbnailGenerator) produceThumbnail() *pb.SessionRecordingThumbnail {
|
||||
cols, rows := t.vt.Size()
|
||||
cursor := t.vt.Cursor()
|
||||
|
||||
return &pb.SessionRecordingThumbnail{
|
||||
Svg: terminal.VtToSvg(t.vt),
|
||||
Cols: int32(cols),
|
||||
Rows: int32(rows),
|
||||
CursorX: int32(cursor.X),
|
||||
CursorY: int32(cursor.Y),
|
||||
CursorVisible: t.vt.CursorVisible(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
)
|
||||
|
||||
func TestTtyThumbnailGenerator_HandleEvent(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
|
||||
t.Run("session start sets terminal size", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "100:50")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
|
||||
require.Equal(t, int32(100), thumb.Cols)
|
||||
require.Equal(t, int32(50), thumb.Rows)
|
||||
})
|
||||
|
||||
t.Run("resize updates terminal size", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, gen.handleEvent(resizeEvent(startTime.Add(1*time.Second), "120:40")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
|
||||
require.Equal(t, int32(120), thumb.Cols)
|
||||
require.Equal(t, int32(40), thumb.Rows)
|
||||
})
|
||||
|
||||
t.Run("session print writes data to terminal", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "Hello World\r\n")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
|
||||
require.NotEmpty(t, thumb.Svg)
|
||||
require.Contains(t, string(thumb.Svg), "Hello")
|
||||
require.Contains(t, string(thumb.Svg), "World")
|
||||
})
|
||||
|
||||
t.Run("unhandled event types are ignored", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(&apievents.SessionEnd{
|
||||
Metadata: apievents.Metadata{Type: "session.end", Time: startTime},
|
||||
}))
|
||||
require.NoError(t, gen.handleEvent(&apievents.SessionJoin{
|
||||
Metadata: apievents.Metadata{Type: "session.join", Time: startTime},
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("malformed terminal size returns error on start", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
err := gen.handleEvent(sessionStartEvent(startTime, "invalid"))
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parsing terminal size")
|
||||
})
|
||||
|
||||
t.Run("malformed terminal size returns error on resize", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
err := gen.handleEvent(resizeEvent(startTime, "not:valid:size"))
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parsing terminal size")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTtyThumbnailGenerator_ProduceThumbnail(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
|
||||
t.Run("produces valid thumbnail with all fields", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "hello\r\n")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
|
||||
require.NotNil(t, thumb)
|
||||
require.Equal(t, int32(80), thumb.Cols)
|
||||
require.Equal(t, int32(24), thumb.Rows)
|
||||
require.NotEmpty(t, thumb.Svg)
|
||||
require.Contains(t, string(thumb.Svg), "<svg")
|
||||
require.Contains(t, string(thumb.Svg), "hello")
|
||||
})
|
||||
|
||||
t.Run("cursor tracks print output position", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
|
||||
thumb0 := gen.produceThumbnail()
|
||||
require.Equal(t, int32(0), thumb0.CursorX)
|
||||
require.Equal(t, int32(0), thumb0.CursorY)
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "line1\r\nline2\r\nline3\r\n")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
require.Equal(t, int32(0), thumb.CursorX, "cursor should be at start of line after \\r\\n")
|
||||
require.Equal(t, int32(3), thumb.CursorY, "cursor should be on 4th row after 3 lines")
|
||||
})
|
||||
|
||||
t.Run("reflects latest terminal size after resize", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "before resize\r\n")))
|
||||
require.NoError(t, gen.handleEvent(resizeEvent(startTime.Add(2*time.Second), "200:50")))
|
||||
|
||||
thumb := gen.produceThumbnail()
|
||||
|
||||
require.Equal(t, int32(200), thumb.Cols)
|
||||
require.Equal(t, int32(50), thumb.Rows)
|
||||
require.Contains(t, string(thumb.Svg), "before")
|
||||
require.Contains(t, string(thumb.Svg), "resize")
|
||||
})
|
||||
|
||||
t.Run("snapshots are independent after more output", func(t *testing.T) {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "first\r\n")))
|
||||
|
||||
thumb1 := gen.produceThumbnail()
|
||||
|
||||
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(2*time.Second), "second\r\n")))
|
||||
|
||||
thumb2 := gen.produceThumbnail()
|
||||
|
||||
require.NotEqual(t, thumb1.Svg, thumb2.Svg, "thumbnails should differ after more output")
|
||||
require.NotContains(t, string(thumb1.Svg), "second", "first snapshot should not contain later output")
|
||||
require.Contains(t, string(thumb2.Svg), "first", "second snapshot should still contain earlier output")
|
||||
require.Contains(t, string(thumb2.Svg), "second")
|
||||
require.Greater(t, thumb2.CursorY, thumb1.CursorY, "cursor should have advanced")
|
||||
})
|
||||
}
|
||||
@@ -24,9 +24,16 @@ import (
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
)
|
||||
|
||||
type SessionType int
|
||||
|
||||
const (
|
||||
SessionTypeUnspecified SessionType = iota
|
||||
SessionTypeTTY
|
||||
)
|
||||
|
||||
// Service defines an interface for processing session recordings.
|
||||
type Service interface {
|
||||
// ProcessSessionRecording processes the session recording associated with the
|
||||
// provided session ID.
|
||||
ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error
|
||||
ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType SessionType, duration time.Duration) error
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ func TestCallingSummarizerMetadata(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
metadataProvider := recordingmetadata.NewProvider()
|
||||
recorderMetadata := &fakeRecordingMetadata{}
|
||||
recorderMetadata.On("ProcessSessionRecording", mock.Anything, session.ID(sessionID.String()), mock.Anything).
|
||||
recorderMetadata.On("ProcessSessionRecording", mock.Anything, session.ID(sessionID.String()), mock.Anything, mock.Anything).
|
||||
Return(nil).Once()
|
||||
metadataProvider.SetService(recorderMetadata)
|
||||
|
||||
@@ -439,8 +439,8 @@ type fakeRecordingMetadata struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, duration)
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, sessionType, duration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -359,6 +359,7 @@ func (u *UploadCompleter) ensureSessionEndEvent(ctx context.Context, uploadData
|
||||
// for both Desktop and SSH sessions
|
||||
var lastEvent events.AuditEvent
|
||||
var startTime time.Time
|
||||
var sessionType recordingmetadata.SessionType
|
||||
var isPTYSession bool
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -396,6 +397,7 @@ loop:
|
||||
desktopSessionEnd.DesktopName = fmt.Sprintf("%v (recovered)", e.DesktopName)
|
||||
|
||||
case *events.SessionStart:
|
||||
sessionType = recordingmetadata.SessionTypeTTY
|
||||
isPTYSession = true
|
||||
startTime = e.Time
|
||||
sshSessionEnd.Type = SessionEndEvent
|
||||
@@ -413,6 +415,9 @@ loop:
|
||||
sshSessionEnd.Interactive = e.TerminalSize != ""
|
||||
sshSessionEnd.Participants = append(sshSessionEnd.Participants, transformedUsername(e.UserMetadata, u.cfg.ClusterName))
|
||||
|
||||
case *events.DatabaseSessionStart:
|
||||
startTime = e.Time
|
||||
|
||||
case *events.SessionJoin:
|
||||
sshSessionEnd.Participants = append(sshSessionEnd.Participants, transformedUsername(e.UserMetadata, u.cfg.ClusterName))
|
||||
}
|
||||
@@ -466,7 +471,7 @@ loop:
|
||||
recordingMetadata := u.cfg.RecordingMetadataProvider.Service()
|
||||
if !startTime.IsZero() && !sessionEndEvent.GetTime().IsZero() {
|
||||
duration := sessionEndEvent.GetTime().Sub(startTime)
|
||||
if err := recordingMetadata.ProcessSessionRecording(ctx, uploadData.SessionID, duration); err != nil {
|
||||
if err := recordingMetadata.ProcessSessionRecording(ctx, uploadData.SessionID, sessionType, duration); err != nil {
|
||||
slog.WarnContext(ctx, "Failed to process session recording metadata", "error", err)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -67,7 +67,7 @@ func Process(ctx context.Context, cfg Config) error {
|
||||
metadataSvc := cfg.RecordingMetadataProvider.Service()
|
||||
if !o.EndTime.IsZero() && !o.StartTime.IsZero() {
|
||||
duration := o.EndTime.Sub(o.StartTime)
|
||||
if err := metadataSvc.ProcessSessionRecording(ctx, cfg.SessionID, duration); err != nil {
|
||||
if err := metadataSvc.ProcessSessionRecording(ctx, cfg.SessionID, recordingmetadata.SessionTypeTTY, duration); err != nil {
|
||||
metadataErr = trace.Wrap(err, "failed to process session recording metadata")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ func TestSessionPostProcessor(t *testing.T) {
|
||||
mock.Anything,
|
||||
sessionID,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).
|
||||
Return(nil).Once()
|
||||
metadataProvider.SetService(recorderMetadata)
|
||||
@@ -82,8 +83,8 @@ type fakeRecordingMetadata struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, duration)
|
||||
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
|
||||
args := f.Called(ctx, sessionID, sessionType, duration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -571,6 +571,8 @@ type sliceWriter struct {
|
||||
sessionStartTime time.Time
|
||||
// sessionEndTime is the time of the last event in the session
|
||||
sessionEndTime time.Time
|
||||
// sessionType is the type of the session, used for recording metadata processing
|
||||
sessionType recordingmetadata.SessionType
|
||||
// shouldProcessSession is set to true if the session should be processed
|
||||
// by the recording metadata service (currently, this is true if the session
|
||||
// is a SSH session).
|
||||
@@ -694,6 +696,7 @@ func (w *sliceWriter) receiveAndUpload() error {
|
||||
switch e := event.oneof.GetEvent().(type) {
|
||||
case *apievents.OneOf_SessionStart:
|
||||
w.sessionStartTime = e.SessionStart.Time
|
||||
w.sessionType = recordingmetadata.SessionTypeTTY
|
||||
w.shouldProcessSession = true
|
||||
|
||||
case *apievents.OneOf_SessionPrint:
|
||||
@@ -843,7 +846,7 @@ func (w *sliceWriter) completeStream() {
|
||||
if !w.sessionStartTime.IsZero() && !w.sessionEndTime.IsZero() {
|
||||
duration := w.sessionEndTime.Sub(w.sessionStartTime)
|
||||
|
||||
if err := recordingMetadata.ProcessSessionRecording(w.proto.cancelCtx, w.proto.cfg.Upload.SessionID, duration); err != nil {
|
||||
if err := recordingMetadata.ProcessSessionRecording(w.proto.cancelCtx, w.proto.cfg.Upload.SessionID, w.sessionType, duration); err != nil {
|
||||
slog.WarnContext(w.proto.cancelCtx, "Failed to process session recording metadata", "error", err)
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user