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()
This commit is contained in:
Ryan Clark
2026-04-21 08:14:51 +00:00
committed by GitHub
parent c6f4cc8230
commit bfbbd7bcce
8 changed files with 1031 additions and 5 deletions
+27
View File
@@ -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 <stdint.h>
@@ -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),
}
}
@@ -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{} }
+59 -5
View File
@@ -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<F: FnOnce() -> R, R>(f: F) -> Result<R, ()> {
catch_unwind(AssertUnwindSafe(f)).map_err(|e| {
// If dropping the original panic payload causes another panic,
+235
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)))
}
+147
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
// 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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}