From bfbbd7bcceec24ef6cf72f383d0102dbda77c56f Mon Sep 17 00:00:00 2001 From: Ryan Clark Date: Tue, 21 Apr 2026 10:14:51 +0200 Subject: [PATCH] Add RDP state tracker for processing desktop recordings (#65140) * Add RDP state tracker for processing desktop recordings * Guard against oversized screen dimensions * Reset cursor state on resize * Constrain screen dimensions to the same as we constrain RDP * Move fastpath test helpers to separate package for reuse * Remove mutex * Do not use require in test module, fix linting issues * Appease the linter * Use DecodePermissive * Avoid import cycle by inlining minimal message decoding in rdpstate * Add length check to legacy fast path PDU * t.Parallel() --- lib/srv/desktop/rdp/decoder/decoder.go | 27 ++ lib/srv/desktop/rdp/decoder/decoder_nop.go | 5 + lib/srv/desktop/rdp/decoder/src/lib.rs | 64 +++- lib/srv/desktop/rdpstate/rdpstate.go | 235 +++++++++++++ lib/srv/desktop/rdpstate/rdpstate_rdp_test.go | 310 ++++++++++++++++++ lib/srv/desktop/rdpstate/rdpstate_test.go | 147 +++++++++ .../desktop/rdpstate/rdpstatetest/events.go | 76 +++++ .../desktop/rdpstate/rdpstatetest/fastpath.go | 172 ++++++++++ 8 files changed, 1031 insertions(+), 5 deletions(-) create mode 100644 lib/srv/desktop/rdpstate/rdpstate.go create mode 100644 lib/srv/desktop/rdpstate/rdpstate_rdp_test.go create mode 100644 lib/srv/desktop/rdpstate/rdpstate_test.go create mode 100644 lib/srv/desktop/rdpstate/rdpstatetest/events.go create mode 100644 lib/srv/desktop/rdpstate/rdpstatetest/fastpath.go diff --git a/lib/srv/desktop/rdp/decoder/decoder.go b/lib/srv/desktop/rdp/decoder/decoder.go index b1543894950..39735a5736e 100644 --- a/lib/srv/desktop/rdp/decoder/decoder.go +++ b/lib/srv/desktop/rdp/decoder/decoder.go @@ -40,6 +40,8 @@ package decoder #cgo noescape rdp_decoder_process #cgo nocallback rdp_decoder_image_data #cgo noescape rdp_decoder_image_data +#cgo nocallback rdp_decoder_cursor_state +#cgo noescape rdp_decoder_cursor_state #include @@ -51,6 +53,7 @@ void rdp_decoder_free(RdpDecoder* ptr); void rdp_decoder_resize(RdpDecoder* ptr, uint16_t width, uint16_t height); void rdp_decoder_process(RdpDecoder* ptr, const uint8_t* data, size_t len); const uint8_t* rdp_decoder_image_data(RdpDecoder* ptr, uint16_t* width, uint16_t* height); +void rdp_decoder_cursor_state(RdpDecoder* ptr, uint8_t* out_visible, uint16_t* out_x, uint16_t* out_y); */ import "C" @@ -67,6 +70,11 @@ type Decoder struct { ptr *C.RdpDecoder } +type CursorState struct { + Visible bool + X, Y uint16 +} + func New(width, height uint16) (*Decoder, error) { ptr := C.rdp_decoder_new(C.uint16_t(width), C.uint16_t(height)) if ptr == nil { @@ -163,3 +171,22 @@ func (d *Decoder) Thumbnail(width, height int) *image.RGBA { return thumbnail } + +// CursorState returns the cursor position and visibility as tracked by the +// Rust decoder. +func (d *Decoder) CursorState() CursorState { + if d == nil || d.ptr == nil { + return CursorState{} + } + + var outVisible C.uint8_t + var outX, outY C.uint16_t + + C.rdp_decoder_cursor_state(d.ptr, &outVisible, &outX, &outY) + + return CursorState{ + Visible: outVisible != 0, + X: uint16(outX), + Y: uint16(outY), + } +} diff --git a/lib/srv/desktop/rdp/decoder/decoder_nop.go b/lib/srv/desktop/rdp/decoder/decoder_nop.go index 1b3eff3108a..0d90dbbd00c 100644 --- a/lib/srv/desktop/rdp/decoder/decoder_nop.go +++ b/lib/srv/desktop/rdp/decoder/decoder_nop.go @@ -25,6 +25,10 @@ import ( ) type Decoder struct{} +type CursorState struct { + Visible bool + X, Y uint16 +} func New(width, height uint16) (*Decoder, error) { return nil, trace.NotImplemented("the RDP decoder is not included in this build") @@ -35,3 +39,4 @@ func (d *Decoder) Resize(w, h uint16) {} func (d *Decoder) Process([]byte) {} func (d *Decoder) Image() *image.RGBA { return nil } func (d *Decoder) Thumbnail(w, h int) *image.RGBA { return nil } +func (d *Decoder) CursorState() CursorState { return CursorState{} } diff --git a/lib/srv/desktop/rdp/decoder/src/lib.rs b/lib/srv/desktop/rdp/decoder/src/lib.rs index f1dd26f9c31..0fc0751c33a 100644 --- a/lib/srv/desktop/rdp/decoder/src/lib.rs +++ b/lib/srv/desktop/rdp/decoder/src/lib.rs @@ -23,14 +23,23 @@ use std::ptr; use ironrdp_core::WriteBuf; use ironrdp_graphics::image_processing::PixelFormat; +use ironrdp_session::fast_path::UpdateKind; use ironrdp_session::{ fast_path::{Processor, ProcessorBuilder}, image::DecodedImage, }; +#[derive(Default)] +struct CursorState { + visible: bool, + x: u16, + y: u16, +} + pub struct RdpDecoder { image: DecodedImage, fast_path_processor: Processor, + cursor_state: CursorState, } impl RdpDecoder { @@ -40,20 +49,24 @@ impl RdpDecoder { Self { image: DecodedImage::new(RdpDecoder::PIXEL_FORMAT, width, height), fast_path_processor: ProcessorBuilder { + // Enable pointer updates so we can get the state of the cursor for when we create + // cropped & zoomed in thumbnails in the session recording metadata generation. + enable_server_pointer: true, // These options only matter in a real RDP session when we have // to send responses back to the server. We can safely leave them // at defaults when decoding session recordings. io_channel_id: 0, user_channel_id: 0, - enable_server_pointer: false, pointer_software_rendering: false, } .build(), + cursor_state: Default::default(), } } pub fn resize(&mut self, width: u16, height: u16) { self.image = DecodedImage::new(RdpDecoder::PIXEL_FORMAT, width, height); + self.cursor_state = Default::default(); } pub fn image_data(&self) -> &[u8] { @@ -72,12 +85,28 @@ impl RdpDecoder { let mut output = WriteBuf::new(); // In a live RDP connection, this would return data that we need - // to use to create reponses to send to the server. + // to use to create responses to send to the server. // We're only interested in updating the internal frame buffer, // so we can ignore the result. - let _ = self - .fast_path_processor - .process(&mut self.image, tdp_fast_path_frame, &mut output); + if let Ok(updates) = + self.fast_path_processor + .process(&mut self.image, tdp_fast_path_frame, &mut output) + { + for update in updates { + match update { + UpdateKind::PointerBitmap(_) => { + self.cursor_state.visible = true; + } + UpdateKind::PointerDefault => self.cursor_state.visible = true, + UpdateKind::PointerHidden => self.cursor_state.visible = false, + UpdateKind::PointerPosition { x, y } => { + self.cursor_state.x = x; + self.cursor_state.y = y; + } + _ => {} + } + } + } } } @@ -184,6 +213,31 @@ pub unsafe extern "C" fn rdp_decoder_image_data( } } +/// Returns the current cursor position and visibility state. +/// +/// # Safety +/// +/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`. +/// - All out-params must be valid, non-null pointers. +#[no_mangle] +pub unsafe extern "C" fn rdp_decoder_cursor_state( + ptr: *mut RdpDecoder, + out_visible: *mut u8, + out_x: *mut u16, + out_y: *mut u16, +) { + if ptr.is_null() || out_visible.is_null() || out_x.is_null() || out_y.is_null() { + return; + } + + let _ = catch_unwind_and_drop_panic_payload(AssertUnwindSafe(move || unsafe { + let decoder = &*ptr; + *out_visible = decoder.cursor_state.visible as u8; + *out_x = decoder.cursor_state.x; + *out_y = decoder.cursor_state.y; + })); +} + fn catch_unwind_and_drop_panic_payload R, R>(f: F) -> Result { catch_unwind(AssertUnwindSafe(f)).map_err(|e| { // If dropping the original panic payload causes another panic, diff --git a/lib/srv/desktop/rdpstate/rdpstate.go b/lib/srv/desktop/rdpstate/rdpstate.go new file mode 100644 index 00000000000..2e722f16711 --- /dev/null +++ b/lib/srv/desktop/rdpstate/rdpstate.go @@ -0,0 +1,235 @@ +/* + * 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 . + */ + +package rdpstate + +import ( + "bytes" + "encoding/binary" + "image" + "io" + + "github.com/gravitational/trace" + "google.golang.org/protobuf/proto" + + tdpbv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/desktop/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/srv/desktop/rdp/decoder" +) + +const ( + // Legacy TDP message types relevant for recording replay. + legacyTypeConnectionActivated = 31 + legacyTypeRDPFastPathPDU = 29 + + // tdpbHeaderLength is the size of the TDPB message length prefix. + tdpbHeaderLength = 4 + + // maxMessageLength is the maximum allowed TDPB message body size (16 MiB - 1). + maxMessageLength = (1 << 24) - 1 +) + +// RDPState reconstructs the screen state of a desktop session by processing a sequence of DesktopRecording events. +// It supports two recording formats: +// - Legacy TDP (evt.Message): ConnectionActivated + RDPFastPathPDU messages, which are translated to modern TDPB +// types before processing. +// - Modern TDPB (evt.TDPBMessage): ServerHello + FastPathPDU messages. +// +// The RDP decoder is initialized on the first ServerHello (or its legacy equivalent, ConnectionActivated). +// Subsequent FastPathPDU messages are fed to the decoder, which maintains the screen framebuffer and cursor state. +type RDPState struct { + decoder *decoder.Decoder +} + +// New creates a new RDPState. +func New() *RDPState { + return &RDPState{} +} + +// HandleMessage processes a single DesktopRecording event. +// Events must be fed in recording order. Only one of evt.Message (legacy TDP) or evt.TDPBMessage (modern TDPB) should +// be set; if both are empty the call is a no-op. +func (s *RDPState) HandleMessage(evt *events.DesktopRecording) error { + if len(evt.TDPBMessage) > 0 { + return trace.Wrap(s.processTDPBMessage(evt.TDPBMessage), "processing TDPB message") + } + + if len(evt.Message) > 0 { + return trace.Wrap(s.processTDPMessage(evt.Message), "processing legacy TDP message") + } + + return nil +} + +// CursorState returns the current cursor visibility and position. If the decoder has not been initialized yet, it +// returns a default hidden cursor at (0, 0). +func (s *RDPState) CursorState() decoder.CursorState { + if s.decoder == nil { + return decoder.CursorState{} + } + + return s.decoder.CursorState() +} + +// Image returns the current screen image as an RGBA bitmap. If the decoder has not been initialized yet, it returns nil. +func (s *RDPState) Image() *image.RGBA { + if s.decoder == nil { + return nil + } + + return s.decoder.Image() +} + +// Release frees any resources associated with the RDPState, including the decoder. +func (s *RDPState) Release() { + if s.decoder != nil { + s.decoder.Release() + s.decoder = nil + } +} + +type connectionActivated struct { + IOChannelID, UserChannelID, ScreenWidth, ScreenHeight uint16 +} + +func (s *RDPState) processTDPMessage(data []byte) error { + if len(data) == 0 { + return trace.BadParameter("empty legacy TDP message") + } + + msgType := data[0] + r := bytes.NewReader(data[1:]) + + switch msgType { + case legacyTypeConnectionActivated: + var ca connectionActivated + if err := binary.Read(r, binary.BigEndian, &ca); err != nil { + return trace.Wrap(err, "decoding legacy ConnectionActivated") + } + + return s.handleServerHello(&tdpbv1.ServerHello{ + ActivationSpec: &tdpbv1.ConnectionActivated{ + IoChannelId: uint32(ca.IOChannelID), + UserChannelId: uint32(ca.UserChannelID), + ScreenWidth: uint32(ca.ScreenWidth), + ScreenHeight: uint32(ca.ScreenHeight), + }, + }) + + case legacyTypeRDPFastPathPDU: + var dataLen uint32 + if err := binary.Read(r, binary.BigEndian, &dataLen); err != nil { + return trace.Wrap(err, "reading legacy RDPFastPathPDU length") + } + + if dataLen >= maxMessageLength { + return trace.BadParameter("legacy RDPFastPathPDU length %d exceeds maximum %d", dataLen, maxMessageLength) + } + + pdu := make([]byte, dataLen) + if _, err := io.ReadFull(r, pdu); err != nil { + return trace.Wrap(err, "reading legacy RDPFastPathPDU data") + } + + return s.handleFastPathPDU(&tdpbv1.FastPathPDU{Pdu: pdu}) + } + + return nil +} + +func (s *RDPState) processTDPBMessage(data []byte) error { + r := bytes.NewReader(data) + + header := make([]byte, tdpbHeaderLength) + if _, err := io.ReadFull(r, header); err != nil { + return trace.Wrap(err, "reading TDPB header") + } + + msgLen := binary.BigEndian.Uint32(header) + if msgLen >= maxMessageLength { + return trace.BadParameter("TDPB message length %d exceeds maximum %d", msgLen, maxMessageLength) + } + + msg := make([]byte, msgLen) + if _, err := io.ReadFull(r, msg); err != nil { + return trace.Wrap(err, "reading TDPB body") + } + + env := &tdpbv1.Envelope{} + if err := proto.Unmarshal(msg, env); err != nil { + return trace.Wrap(err, "unmarshalling TDPB envelope") + } + + switch m := env.Payload.(type) { + case *tdpbv1.Envelope_ServerHello: + return s.handleServerHello(m.ServerHello) + case *tdpbv1.Envelope_FastPathPdu: + return s.handleFastPathPDU(m.FastPathPdu) + } + + return nil +} + +func (s *RDPState) handleServerHello(msg *tdpbv1.ServerHello) error { + spec := msg.GetActivationSpec() + if spec == nil { + return nil + } + + sw, sh := spec.GetScreenWidth(), spec.GetScreenHeight() + if sw > types.MaxRDPScreenWidth || sh > types.MaxRDPScreenHeight { + return trace.BadParameter("screen dimensions (%d x %d) exceed maximum (%d x %d)", + sw, sh, types.MaxRDPScreenWidth, types.MaxRDPScreenHeight) + } + + w := uint16(sw) + h := uint16(sh) + + if w == 0 || h == 0 { + return nil + } + + if s.decoder == nil { + d, err := decoder.New(w, h) //nolint:staticcheck // err is always non-nil in nop build but nil in RDP build + if err != nil { //nolint:staticcheck // err is always non-nil in nop build but nil in RDP build + return trace.Wrap(err, "creating RDP decoder") + } + + s.decoder = d + } else { + s.decoder.Resize(w, h) + } + + return nil +} + +func (s *RDPState) handleFastPathPDU(msg *tdpbv1.FastPathPDU) error { + pdu := msg.GetPdu() + if len(pdu) == 0 { + return nil + } + + if s.decoder == nil { + return trace.BadParameter("received FastPathPDU before ServerHello initialized decoder") + } + + s.decoder.Process(pdu) + + return nil +} diff --git a/lib/srv/desktop/rdpstate/rdpstate_rdp_test.go b/lib/srv/desktop/rdpstate/rdpstate_rdp_test.go new file mode 100644 index 00000000000..91620d690ef --- /dev/null +++ b/lib/srv/desktop/rdpstate/rdpstate_rdp_test.go @@ -0,0 +1,310 @@ +//go:build desktop_access_rdp || rust_rdp_decoder + +/* + * 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 . + */ + +package rdpstate + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/srv/desktop/rdp/decoder" + "github.com/gravitational/teleport/lib/srv/desktop/rdpstate/rdpstatetest" +) + +func TestServerHello_CreatesDecoder(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 800, 600))) + require.NotNil(t, s.decoder) + + img := s.Image() + require.NotNil(t, img) + + require.Equal(t, 800, img.Bounds().Dx()) + require.Equal(t, 600, img.Bounds().Dy()) +} + +func TestServerHello_Resize(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 800, 600))) + + d := s.decoder + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 1920, 1080))) + + require.Same(t, d, s.decoder) +} + +func TestFastPathPDU_UpdatesImage(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + + // Draw a 4x2 white rectangle at (10, 20). + sendPDU(t, s, rdpstatetest.BuildBitmapPDU(10, 20, 4, 2, rdpstatetest.RGB565White)) + + img := s.Image() + require.NotNil(t, img) + + // Pixel inside the bitmap should be white. + r, g, b, a := img.At(10, 20).RGBA() + require.Equal(t, uint32(0xFFFF), r) + require.Equal(t, uint32(0xFFFF), g) + require.Equal(t, uint32(0xFFFF), b) + require.Equal(t, uint32(0xFFFF), a) + + // Pixel outside should be untouched (black/transparent). + r, g, b, a = img.At(99, 99).RGBA() + require.Equal(t, uint32(0), r) + require.Equal(t, uint32(0), g) + require.Equal(t, uint32(0), b) + require.Equal(t, uint32(0), a) +} + +func TestFastPathPDU_MultipleBitmaps(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + + // Red at row 0, blue at row 1. + sendPDU(t, s, rdpstatetest.BuildBitmapPDU(0, 0, 4, 1, rdpstatetest.RGB565Red)) + sendPDU(t, s, rdpstatetest.BuildBitmapPDU(0, 1, 4, 1, rdpstatetest.RGB565Blue)) + + img := s.Image() + require.NotNil(t, img) + + // Red pixel (RGB565 0xF800 -> R>0, G=0, B=0). + r, g, b, _ := img.At(0, 0).RGBA() + require.NotEqual(t, uint32(0), r, "expected red channel to be non-zero") + require.Equal(t, uint32(0), g) + require.Equal(t, uint32(0), b) + + // Blue pixel (RGB565 0x001F -> R=0, G=0, B>0). + r, g, b, _ = img.At(0, 1).RGBA() + require.Equal(t, uint32(0), r) + require.Equal(t, uint32(0), g) + require.NotEqual(t, uint32(0), b, "expected blue channel to be non-zero") +} + +func TestFastPathPDU_AfterResize(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + sendPDU(t, s, rdpstatetest.BuildBitmapPDU(0, 0, 4, 1, rdpstatetest.RGB565White)) + + // Resize clears the framebuffer. + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 200, 200))) + + img := s.Image() + require.NotNil(t, img) + require.Equal(t, 200, img.Bounds().Dx()) + require.Equal(t, 200, img.Bounds().Dy()) + + // Old content should be gone. + r, g, b, a := img.At(0, 0).RGBA() + require.Equal(t, uint32(0), r) + require.Equal(t, uint32(0), g) + require.Equal(t, uint32(0), b) + require.Equal(t, uint32(0), a) + + // New PDU after resize still works. + sendPDU(t, s, rdpstatetest.BuildBitmapPDU(0, 0, 4, 1, rdpstatetest.RGB565White)) + img = s.Image() + require.NotNil(t, img) + + r, g, b, a = img.At(0, 0).RGBA() + require.Equal(t, uint32(0xFFFF), r) + require.Equal(t, uint32(0xFFFF), g) + require.Equal(t, uint32(0xFFFF), b) + require.Equal(t, uint32(0xFFFF), a) +} + +func TestLegacyTDP_ConnectionActivated(t *testing.T) { + t.Parallel() + + s := New() + + data := legacyConnectionActivated(t, 1024, 768) + require.NoError(t, s.HandleMessage(data)) + require.NotNil(t, s.decoder) + + img := s.Image() + require.NotNil(t, img) + + require.Equal(t, 1024, img.Bounds().Dx()) + require.Equal(t, 768, img.Bounds().Dy()) +} + +func TestLegacyTDP_Resize(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(legacyConnectionActivated(t, 800, 600))) + d := s.decoder + + require.NoError(t, s.HandleMessage(legacyConnectionActivated(t, 1920, 1080))) + require.Same(t, d, s.decoder) + + img := s.Image() + require.NotNil(t, img) + require.Equal(t, 1920, img.Bounds().Dx()) + require.Equal(t, 1080, img.Bounds().Dy()) +} + +func TestCursorState_DefaultHidden(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 800, 600))) + + cs := s.CursorState() + require.False(t, cs.Visible) + require.Equal(t, uint16(0), cs.X) + require.Equal(t, uint16(0), cs.Y) +} + +func TestCursorState_BeforeServerHello(t *testing.T) { + t.Parallel() + + s := New() + + cs := s.CursorState() + require.False(t, cs.Visible) + require.Equal(t, uint16(0), cs.X) + require.Equal(t, uint16(0), cs.Y) +} + +func TestCursorState_PointerPosition(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + + sendPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + sendPDU(t, s, rdpstatetest.BuildPointerPositionPDU(50, 60)) + + cs := s.CursorState() + require.Equal(t, decoder.CursorState{Visible: true, X: 50, Y: 60}, cs) +} + +func TestCursorState_PointerHidden(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + + sendPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + require.True(t, s.CursorState().Visible) + + sendPDU(t, s, rdpstatetest.BuildPointerHiddenPDU()) + require.False(t, s.CursorState().Visible) +} + +func TestCursorState_PointerDefaultRestoresVisibility(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100))) + + sendPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + sendPDU(t, s, rdpstatetest.BuildPointerHiddenPDU()) + require.False(t, s.CursorState().Visible) + + sendPDU(t, s, rdpstatetest.BuildPointerDefaultPDU()) + require.True(t, s.CursorState().Visible) +} + +func TestLegacyCursorState_PointerPosition(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(legacyConnectionActivated(t, 100, 100))) + + sendLegacyPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + sendLegacyPDU(t, s, rdpstatetest.BuildPointerPositionPDU(50, 60)) + + cs := s.CursorState() + require.Equal(t, decoder.CursorState{Visible: true, X: 50, Y: 60}, cs) +} + +func TestLegacyCursorState_PointerHidden(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(legacyConnectionActivated(t, 100, 100))) + + sendLegacyPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + require.True(t, s.CursorState().Visible) + + sendLegacyPDU(t, s, rdpstatetest.BuildPointerHiddenPDU()) + require.False(t, s.CursorState().Visible) +} + +func TestLegacyCursorState_PointerDefaultRestoresVisibility(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(legacyConnectionActivated(t, 100, 100))) + + sendLegacyPDU(t, s, rdpstatetest.BuildNewPointerPDU(2, 2, 0, 0, rdpstatetest.BGRARed)) + sendLegacyPDU(t, s, rdpstatetest.BuildPointerHiddenPDU()) + require.False(t, s.CursorState().Visible) + + sendLegacyPDU(t, s, rdpstatetest.BuildPointerDefaultPDU()) + require.True(t, s.CursorState().Visible) +} + +func sendPDU(t *testing.T, s *RDPState, pdu []byte) { + t.Helper() + + require.NoError(t, s.HandleMessage(encodeTDPBFastPathPDU(t, pdu))) +} + +func sendLegacyPDU(t *testing.T, s *RDPState, pdu []byte) { + t.Helper() + + // Encode as legacy RDPFastPathPDU: type byte (29) + uint32 length + data. + data := make([]byte, 1+4+len(pdu)) + data[0] = 29 // TypeRDPFastPathPDU + binary.BigEndian.PutUint32(data[1:5], uint32(len(pdu))) + copy(data[5:], pdu) + require.NoError(t, s.HandleMessage(rdpstatetest.LegacyEvent(data))) +} diff --git a/lib/srv/desktop/rdpstate/rdpstate_test.go b/lib/srv/desktop/rdpstate/rdpstate_test.go new file mode 100644 index 00000000000..4ba701e0547 --- /dev/null +++ b/lib/srv/desktop/rdpstate/rdpstate_test.go @@ -0,0 +1,147 @@ +/* + * 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 . + */ + +package rdpstate + +import ( + "encoding/binary" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + tdpbv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/desktop/v1" + "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/srv/desktop/rdpstate/rdpstatetest" +) + +// These tests cover message routing, error handling, and edge cases that don't require a real RDP decoder. +// Tests that process actual FastPath PDUs and verify decoded image content are in rdpstate_rdp_test.go (build-tagged). + +func TestHandleMessage_EmptyEvent(t *testing.T) { + t.Parallel() + + s := New() + require.NoError(t, s.HandleMessage(&events.DesktopRecording{})) + require.Nil(t, s.decoder) +} + +func TestHandleMessage_InvalidData(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + evt *events.DesktopRecording + }{ + {"invalid TDPB", rdpstatetest.TDPBEvent([]byte{0xFF, 0xFF, 0xFF})}, + {"truncated legacy", rdpstatetest.LegacyEvent([]byte{29})}, // RDPFastPathPDU type with no payload + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Error(t, New().HandleMessage(tt.evt)) + }) + } +} + +func TestServerHello_NoOp(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + evt *events.DesktopRecording + }{ + {"zero width", encodeTDPBServerHello(t, 0, 600)}, + {"zero height", encodeTDPBServerHello(t, 800, 0)}, + {"legacy zero width", legacyConnectionActivated(t, 0, 600)}, + {"legacy zero height", legacyConnectionActivated(t, 800, 0)}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(tt.evt)) + require.Nil(t, s.decoder) + }) + } +} + +func TestFastPathPDU_BeforeServerHello(t *testing.T) { + t.Parallel() + + s := New() + err := s.HandleMessage(encodeTDPBFastPathPDU(t, []byte{0xDE, 0xAD})) + + require.True(t, trace.IsBadParameter(err), "expected BadParameter, got: %v", err) +} + +func TestFastPathPDU_EmptyPDU(t *testing.T) { + t.Parallel() + + s := New() + + require.NoError(t, s.HandleMessage(encodeTDPBFastPathPDU(t, nil))) + require.Nil(t, s.decoder) +} + +func TestUnknownMessage_Ignored(t *testing.T) { + t.Parallel() + + s := New() + + // Encode a SyncKeys message (not handled by RDPState) as a TDPB envelope. + body, err := proto.Marshal(&tdpbv1.Envelope{ + Payload: &tdpbv1.Envelope_SyncKeys{SyncKeys: &tdpbv1.SyncKeys{}}, + }) + require.NoError(t, err) + data := make([]byte, 4+len(body)) + binary.BigEndian.PutUint32(data[:4], uint32(len(body))) + copy(data[4:], body) + + require.NoError(t, s.HandleMessage(rdpstatetest.TDPBEvent(data))) + require.Nil(t, s.decoder) +} + +func encodeTDPBServerHello(t *testing.T, width, height uint32) *events.DesktopRecording { + t.Helper() + + evt, err := rdpstatetest.EncodeTDPBServerHello(width, height) + require.NoError(t, err) + + return evt +} + +func encodeTDPBFastPathPDU(t *testing.T, pdu []byte) *events.DesktopRecording { + t.Helper() + + evt, err := rdpstatetest.EncodeTDPBFastPathPDU(pdu) + require.NoError(t, err) + + return evt +} + +func legacyConnectionActivated(t *testing.T, width, height uint16) *events.DesktopRecording { + t.Helper() + + evt, err := rdpstatetest.LegacyConnectionActivated(width, height) + require.NoError(t, err) + + return evt +} diff --git a/lib/srv/desktop/rdpstate/rdpstatetest/events.go b/lib/srv/desktop/rdpstate/rdpstatetest/events.go new file mode 100644 index 00000000000..b5612571eee --- /dev/null +++ b/lib/srv/desktop/rdpstate/rdpstatetest/events.go @@ -0,0 +1,76 @@ +/** + * 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 . + */ + +// Package rdpstatetest provides shared test helpers for constructing +// DesktopRecording events and raw FastPath PDUs used in RDP-related tests. +package rdpstatetest + +import ( + tdpbv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/desktop/v1" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/legacy" + "github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/tdpb" +) + +// TDPBEvent wraps raw TDPB-encoded bytes in a DesktopRecording event. +func TDPBEvent(data []byte) *apievents.DesktopRecording { + return &apievents.DesktopRecording{TDPBMessage: data} +} + +// LegacyEvent wraps raw legacy-TDP-encoded bytes in a DesktopRecording event. +func LegacyEvent(data []byte) *apievents.DesktopRecording { + return &apievents.DesktopRecording{Message: data} +} + +// EncodeTDPBServerHello creates a DesktopRecording event containing a TDPB ServerHello message with the given screen +// dimensions. +func EncodeTDPBServerHello(width, height uint32) (*apievents.DesktopRecording, error) { + data, err := (&tdpb.ServerHello{ + ActivationSpec: &tdpbv1.ConnectionActivated{ + ScreenWidth: width, + ScreenHeight: height, + }, + }).Encode() + if err != nil { + return nil, err + } + + return TDPBEvent(data), nil +} + +// EncodeTDPBFastPathPDU creates a DesktopRecording event containing a TDPB FastPathPDU message wrapping the given raw +// PDU bytes. +func EncodeTDPBFastPathPDU(pdu []byte) (*apievents.DesktopRecording, error) { + data, err := (&tdpb.FastPathPDU{Pdu: pdu}).Encode() + if err != nil { + return nil, err + } + + return TDPBEvent(data), nil +} + +// LegacyConnectionActivated creates a DesktopRecording event containing a legacy ConnectionActivated message with the +// given screen dimensions. +func LegacyConnectionActivated(width, height uint16) (*apievents.DesktopRecording, error) { + data, err := legacy.ConnectionActivated{ScreenWidth: width, ScreenHeight: height}.Encode() + if err != nil { + return nil, err + } + + return LegacyEvent(data), nil +} diff --git a/lib/srv/desktop/rdpstate/rdpstatetest/fastpath.go b/lib/srv/desktop/rdpstate/rdpstatetest/fastpath.go new file mode 100644 index 00000000000..f49a5abd66c --- /dev/null +++ b/lib/srv/desktop/rdpstate/rdpstatetest/fastpath.go @@ -0,0 +1,172 @@ +/** + * 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 . + */ + +package rdpstatetest + +import ( + "encoding/binary" + "slices" +) + +const ( + // Bitmap Update Data (MS-RDPBCGR 2.2.9.1.1.3.1.2). + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d681bb11-f3b5-4add-b092-19fe7075f9e3 + + // BitmapUpdateType is the "update header" value for a bitmap update in a FastPath PDU. + BitmapUpdateType = 0x0001 + + // Client Fast-Path Input Event PDU actions (MS-RDPBCGR 2.2.8.1.2). + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/b8e7c588-51cb-455b-bb73-92d480903133 + + // ActionFastPath is the "action" byte value for a FastPath PDU. + ActionFastPath = 0x00 + + // FastPath update codes and fragmentation (MS-RDPBCGR 2.2.9.1.2.1). + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a1c4caa8-00ed-45bb-a06e-5177473766d3 + + // SingleFragmentFlag is the bit flag to set in the update code byte of a FastPath PDU to indicate that the update is contained in a single fragment. + SingleFragmentFlag = 0x00 + + // UpdateCodeBitmap indicates a bitmap update fragment. + UpdateCodeBitmap = 0x01 + // UpdateCodePointerHidden indicates a pointer hidden update fragment. + UpdateCodePointerHidden = 0x05 + // UpdateCodePointerDefault indicates a pointer default update fragment. + UpdateCodePointerDefault = 0x06 + // UpdateCodePointerPos indicates a pointer position update fragment. + UpdateCodePointerPos = 0x08 + // UpdateCodeNewPointer indicates a new pointer update fragment. + UpdateCodeNewPointer = 0x0b + + // RGB565 pixel values for test bitmaps. + + // RGB565White is a white pixel in RGB565 format. + RGB565White = 0xFFFF + // RGB565Red is a red pixel in RGB565 format. + RGB565Red = 0xF800 + // RGB565Blue is a blue pixel in RGB565 format. + RGB565Blue = 0x001F +) + +// BGRARed is a BGRA pixel for a solid red color. +var BGRARed = [4]byte{0x00, 0x00, 0xFF, 0xFF} + +// WrapFastPathUpdate wraps inner update data in a FastPath PDU envelope. +func WrapFastPathUpdate(updateCode byte, innerData []byte) []byte { + // FastPathUpdatePdu + fpUpdate := make([]byte, 3) + fpUpdate[0] = updateCode | SingleFragmentFlag + binary.LittleEndian.PutUint16(fpUpdate[1:], uint16(len(innerData))) + + // FastPathHeader + body := slices.Concat(fpUpdate, innerData) + totalLen := 2 + len(body) + header := []byte{ActionFastPath, byte(totalLen)} + + return slices.Concat(header, body) +} + +// BuildBitmapPDU constructs a minimal valid RDP fast path PDU containing an uncompressed 16bpp bitmap update. +// The bitmap fills a rectangle at (left, top) with dimensions w x h using the given RGB565 pixel value. +func BuildBitmapPDU(left, top, w, h int, rgb565 uint16) []byte { + bitmapData := make([]byte, 18) + binary.LittleEndian.PutUint16(bitmapData[0:], uint16(left)) + binary.LittleEndian.PutUint16(bitmapData[2:], uint16(top)) + binary.LittleEndian.PutUint16(bitmapData[4:], uint16(left+w-1)) // right (inclusive) + binary.LittleEndian.PutUint16(bitmapData[6:], uint16(top+h-1)) // bottom (inclusive) + binary.LittleEndian.PutUint16(bitmapData[8:], uint16(w)) + binary.LittleEndian.PutUint16(bitmapData[10:], uint16(h)) + binary.LittleEndian.PutUint16(bitmapData[12:], 16) // bitsPerPixel + binary.LittleEndian.PutUint16(bitmapData[14:], 0) // flags (uncompressed) + + rowBytes := w * 2 // 2 bytes per pixel at 16bpp + if rowBytes%4 != 0 { + // rows must be 4-byte aligned + rowBytes += 4 - (rowBytes % 4) + } + + pixelDataLen := rowBytes * h + binary.LittleEndian.PutUint16(bitmapData[16:], uint16(pixelDataLen)) + + pixelData := make([]byte, pixelDataLen) + for row := range h { + for col := range w { + binary.LittleEndian.PutUint16(pixelData[row*rowBytes+col*2:], rgb565) + } + } + + // BitmapUpdateData header + bitmapUpdate := make([]byte, 4) + binary.LittleEndian.PutUint16(bitmapUpdate[0:], BitmapUpdateType) + binary.LittleEndian.PutUint16(bitmapUpdate[2:], 1) // number of rectangles + + innerData := slices.Concat(bitmapUpdate, bitmapData, pixelData) + + return WrapFastPathUpdate(UpdateCodeBitmap, innerData) +} + +// BuildNewPointerPDU constructs a FastPath "New Pointer" update with a solid-color 32bpp cursor of the given dimensions +// and BGRA pixel value. +// The resulting DecodedPointer bitmap will be in RGBA format after IronRDP decoding. +func BuildNewPointerPDU(w, h, hotspotX, hotspotY int, bgra [4]byte) []byte { + // XOR mask: 32bpp BGRA pixels, bottom-up scanlines. For 32bpp the stride is always w*4 (always 16-bit aligned). + xorStride := w * 4 + xorMask := make([]byte, xorStride*h) + for row := range h { + for col := range w { + off := row*xorStride + col*4 + xorMask[off] = bgra[0] + xorMask[off+1] = bgra[1] + xorMask[off+2] = bgra[2] + xorMask[off+3] = bgra[3] + } + } + + // TS_POINTERATTRIBUTE (New Pointer): xor_bpp + ColorPointerAttribute + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/584e4438-574c-45f4-947f-b0edcd9ae32c + data := make([]byte, 16) + binary.LittleEndian.PutUint16(data[0:], 32) // xor_bpp + binary.LittleEndian.PutUint16(data[2:], 0) // cache_index + binary.LittleEndian.PutUint16(data[4:], uint16(hotspotX)) // hot_spot.x + binary.LittleEndian.PutUint16(data[6:], uint16(hotspotY)) // hot_spot.y + binary.LittleEndian.PutUint16(data[8:], uint16(w)) // width + binary.LittleEndian.PutUint16(data[10:], uint16(h)) // height + binary.LittleEndian.PutUint16(data[12:], 0) // and_mask_len (empty → default opaque) + binary.LittleEndian.PutUint16(data[14:], uint16(len(xorMask))) + + return WrapFastPathUpdate(UpdateCodeNewPointer, slices.Concat(data, xorMask)) +} + +// BuildPointerPositionPDU constructs a FastPath "Pointer Position" update. +func BuildPointerPositionPDU(x, y int) []byte { + data := make([]byte, 4) + binary.LittleEndian.PutUint16(data[0:], uint16(x)) + binary.LittleEndian.PutUint16(data[2:], uint16(y)) + + return WrapFastPathUpdate(UpdateCodePointerPos, data) +} + +// BuildPointerHiddenPDU constructs a FastPath "Pointer Hidden" update. +func BuildPointerHiddenPDU() []byte { + return WrapFastPathUpdate(UpdateCodePointerHidden, nil) +} + +// BuildPointerDefaultPDU constructs a FastPath "Pointer Default" update. +func BuildPointerDefaultPDU() []byte { + return WrapFastPathUpdate(UpdateCodePointerDefault, nil) +}