Compare commits

..

No commits in common. "master" and "ironrdp-v0.3.0" have entirely different histories.

1188 changed files with 19786 additions and 147291 deletions

View file

@ -1,5 +0,0 @@
[alias]
xtask = "run --package xtask --"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

View file

@ -1,8 +0,0 @@
*.rs text eol=lf
*.toml text eol=lf
*.cs text eol=lf
*.js text eol=lf
*.ps1 text eol=lf
*.sln text eol=crlf
ffi/dotnet/Devolutions.IronRdp/Generated/** linguist-generated merge=binary

3
.github/CODEOWNERS vendored
View file

@ -1,3 +0,0 @@
# File auto-generated and managed by Devops
/.github/ @devolutions/devops @devolutions/architecture-maintainers
/.github/dependabot.yml @devolutions/security-managers

View file

@ -1,28 +0,0 @@
version: 2
updates:
- package-ecosystem: "cargo"
directories:
- "/"
- "/fuzz/"
schedule:
interval: "weekly"
assignees:
- "CBenoit"
open-pull-requests-limit: 3
groups:
crypto:
patterns:
- "md-5"
- "md5"
- "sha1"
- "pkcs1"
- "x509-cert"
- "der"
- "*tls*"
- "*rand*"
patch:
dependency-type: "production"
update-types:
- "patch"
dev:
dependency-type: "development"

View file

@ -1,215 +0,0 @@
name: CI
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
env:
# Disable incremental compilation. CI builds are often closer to from-scratch builds, as changes
# are typically bigger than from a local edit-compile cycle.
# Incremental compilation also significantly increases the amount of IO and the size of ./target
# folder, which makes caching less effective.
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUST_BACKTRACE: short
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# Cache should never takes more than a few seconds to get downloaded.
# If it does, lets just rebuild from scratch instead of hanging "forever".
SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1
# Disabling debug info so compilation is faster and ./target folder is smaller.
CARGO_PROFILE_DEV_DEBUG: 0
jobs:
formatting:
name: Check formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: cargo xtask check fmt -v
typos:
name: Check typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
- name: typos (prepare)
run: cargo xtask check install -v
- name: typos (check)
run: cargo xtask check typos -v
checks:
name: Checks [${{ matrix.os }}]
needs: [formatting]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
os: [windows, linux, macos]
include:
- os: windows
runner: windows-latest
- os: linux
runner: ubuntu-latest
- os: macos
runner: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install devel packages
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get -y install libasound2-dev
- name: Install NASM
if: ${{ runner.os == 'Windows' }}
run: |
choco install nasm
$Env:PATH += ";$Env:ProgramFiles\NASM"
echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV
shell: pwsh
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
# Compilation is separated from execution so we know exactly the time for each step.
- name: Tests (compile)
run: cargo xtask check tests --no-run -v
- name: Tests (run)
run: cargo xtask check tests -v
- name: Lints
run: cargo xtask check lints -v
- name: WASM (prepare)
run: cargo xtask wasm install -v
- name: WASM (check)
run: cargo xtask wasm check -v
- name: Lock files
run: cargo xtask check locks -v
fuzz:
name: Fuzzing
needs: [formatting]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
with:
workspaces: fuzz -> target
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
- name: Prepare
run: cargo xtask fuzz install -v
# Simply run all fuzz targets for a few seconds, just checking there is nothing obviously wrong at a quick glance
- name: Fuzz
run: cargo xtask fuzz run -v
- name: Lock files
run: cargo xtask check locks -v
web:
name: Web Client
needs: [formatting]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
- name: Prepare
run: cargo xtask web install -v
- name: Check
run: cargo xtask web check -v
- name: Lock files
run: cargo xtask check locks -v
ffi:
name: FFI
needs: [formatting]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
- name: Prepare runner
run: cargo xtask ffi install -v
- name: Build native library
run: cargo xtask ffi build -v
- name: Generate bindings
run: cargo xtask ffi bindings -v
- name: Build .NET projects
run: cd ./ffi/dotnet && dotnet build
success:
name: Success
if: ${{ always() }}
needs: [formatting, typos, checks, fuzz, web, ffi]
runs-on: ubuntu-latest
steps:
- name: Check success
run: |
$results = '${{ toJSON(needs.*.result) }}' | ConvertFrom-Json
$succeeded = $($results | Where { $_ -Ne "success" }).Count -Eq 0
exit $(if ($succeeded) { 0 } else { 1 })
shell: pwsh

View file

@ -1,50 +0,0 @@
name: Coverage
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
env:
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
coverage:
name: Coverage Report
runs-on: ubuntu-latest
# Running the coverage job is only supported on the official repo itself, not on forks
# (because $GITHUB_TOKEN only have read permissions when run on a fork)
# We would need something like Codecov integration to handle forks properly
# https://github.com/taiki-e/cargo-llvm-cov#continuous-integration
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
- name: Prepare runner
run: cargo xtask cov install -v
- name: Generate PR report
if: ${{ github.event.number != '' }}
run: cargo xtask cov report-gh --repo "${{ github.repository }}" --pr "${{ github.event.number }}" -v
env:
GH_TOKEN: ${{ github.token }}
- name: Configure Git Identity
if: ${{ github.ref == 'refs/heads/master' }}
run: |
git config --local user.name "github-actions[bot]"
git config --local user.email "github-actions[bot]@users.noreply.github.com"
- name: Update coverage data
if: ${{ github.ref == 'refs/heads/master' }}
run: cargo xtask cov update -v
env:
GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_TOKEN }}

View file

@ -1,182 +0,0 @@
name: Fuzz
on:
workflow_dispatch:
schedule:
- cron: '12 3 * * 0' # At 03:12 AM UTC on Sunday.
env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUST_BACKTRACE: short
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1
jobs:
corpus-download:
name: Download corpus
runs-on: ubuntu-latest
env:
AZURE_STORAGE_KEY: ${{ secrets.CORPUS_AZURE_STORAGE_KEY }}
steps:
- uses: actions/checkout@v4
- name: Download fuzzing corpus
run: cargo xtask fuzz corpus-fetch -v
- name: Save corpus
uses: actions/cache/save@v4
with:
path: |
./fuzz/corpus
./fuzz/artifacts
key: fuzz-corpus-${{ github.run_id }}
fuzz:
name: Fuzzing ${{ matrix.target }}
needs: [corpus-download]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [pdu_decoding, rle_decompression, bitmap_stream, cliprdr_format, channel_processing]
steps:
- uses: actions/checkout@v4
- name: Download corpus
uses: actions/cache/restore@v4
with:
fail-on-cache-miss: true
path: |
./fuzz/corpus
./fuzz/artifacts
key: fuzz-corpus-${{ github.run_id }}
- name: Print corpus
run: |
tree ./fuzz/corpus
tree ./fuzz/artifacts
- name: Rust cache
uses: Swatinem/rust-cache@v2.7.3
with:
workspaces: fuzz -> target
- name: Binary cache
uses: actions/cache@v4
with:
path: ./.cargo/local_root/bin
key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }}
- name: Prepare runner
run: cargo xtask fuzz install -v
- name: Fuzz
run: cargo xtask fuzz run --duration 1000 --target ${{ matrix.target }} -v
- name: Minify fuzzing corpus
if: ${{ always() && !cancelled() }}
run: cargo xtask fuzz corpus-min --target ${{ matrix.target }} -v
# Use GitHub artifacts instead of cache for the updated corpus
# because same cache cant be used by multiple jobs at the same time.
# Also, we cant dynamically create a unique cache keys for all
# the targets, because then we cant easily retrieve this cache
# without hardcoding a step for each one. Its not good for maintenance.
- name: Prepare minified corpus upload
# We want to upload artifacts even if fuzzing "fails" (so we can retrieve the artifact causing the crash)
if: ${{ always() && !cancelled() }}
run: |
mkdir ${{ runner.temp }}/corpus/
cp -r ./fuzz/corpus/${{ matrix.target }} ${{ runner.temp }}/corpus
mkdir ${{ runner.temp }}/artifacts/
cp -r ./fuzz/artifacts/${{ matrix.target }} ${{ runner.temp }}/artifacts
- name: Upload minified corpus
if: ${{ always() && !cancelled() }}
uses: actions/upload-artifact@v4
with:
retention-days: 7
name: minified-corpus-${{ matrix.target }}
path: |
${{ runner.temp }}/corpus
${{ runner.temp }}/artifacts
corpus-merge:
name: Corpus merge artifacts
if: ${{ always() && !cancelled() }}
needs: [fuzz]
runs-on: ubuntu-latest
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
name: minified-corpus
pattern: minified-corpus-*
delete-merged: true
corpus-upload:
name: Upload corpus
if: ${{ always() && !cancelled() }}
needs: [corpus-merge]
runs-on: ubuntu-latest
env:
AZURE_STORAGE_KEY: ${{ secrets.CORPUS_AZURE_STORAGE_KEY }}
steps:
- uses: actions/checkout@v4
- name: Download updated corpus
uses: actions/download-artifact@v4
with:
name: minified-corpus
path: ./fuzz/
- name: Print corpus
run: |
tree ./fuzz/corpus
tree ./fuzz/artifacts
- name: Upload fuzzing corpus
run: cargo xtask fuzz corpus-push -v
- name: Clean corpus cache
run: |
curl -L \
-X DELETE \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ github.token }}"\
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/caches?key=fuzz-corpus-${{ github.run_id }}"
notify:
name: Notify failure
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
needs: [fuzz]
runs-on: ubuntu-latest
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
steps:
- name: Send slack notification
id: slack
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*${{ github.repository }}* :warning: \n Fuzz workflow for *${{ github.repository }}* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|found a bug>"
}
}
]
}

View file

@ -1,234 +0,0 @@
name: Publish npm package
on:
workflow_dispatch:
inputs:
dry-run:
description: 'Dry run'
required: true
type: boolean
default: true
schedule:
- cron: '48 3 * * 1' # 3:48 AM UTC every Monday
jobs:
preflight:
name: Preflight
runs-on: ubuntu-latest
outputs:
dry-run: ${{ steps.get-dry-run.outputs.dry-run }}
steps:
- name: Get dry run
id: get-dry-run
run: |
$IsDryRun = '${{ github.event.inputs.dry-run }}' -Eq 'true' -Or '${{ github.event_name }}' -Eq 'schedule'
if ($IsDryRun) {
echo "dry-run=true" >> $Env:GITHUB_OUTPUT
} else {
echo "dry-run=false" >> $Env:GITHUB_OUTPUT
}
shell: pwsh
build:
name: Build package [${{matrix.library}}]
needs: [preflight]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
library:
- iron-remote-desktop
- iron-remote-desktop-rdp
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup wasm-pack
run: |
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
shell: bash
- name: Install dependencies
run: |
Set-Location -Path "./web-client/${{matrix.library}}/"
npm install
shell: pwsh
- name: Build package
run: |
Set-PSDebug -Trace 1
Set-Location -Path "./web-client/${{matrix.library}}/"
npm run build
Set-Location -Path ./dist
npm pack
shell: pwsh
- name: Harvest package
run: |
Set-PSDebug -Trace 1
New-Item -ItemType "directory" -Path . -Name "npm-packages"
Get-ChildItem -Path ./web-client/ -Recurse *.tgz | ForEach { Copy-Item $_ "./npm-packages" }
shell: pwsh
- name: Upload package artifact
uses: actions/upload-artifact@v4
with:
name: npm-${{matrix.library}}
path: npm-packages/*.tgz
npm-merge:
name: Merge artifacts
needs: [build]
runs-on: ubuntu-latest
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
name: npm
pattern: npm-*
delete-merged: true
publish:
name: Publish package
environment: publish
if: ${{ github.event_name == 'workflow_dispatch' }}
needs: [preflight, npm-merge]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download NPM packages artifact
uses: actions/download-artifact@v4
with:
name: npm
path: npm-packages
- name: Publish
run: |
Set-PSDebug -Trace 1
$isDryRun = '${{ needs.preflight.outputs.dry-run }}' -Eq 'true'
$files = Get-ChildItem -Recurse npm-packages/*.tgz
foreach ($file in $files) {
Write-Host "Processing $($file.Name)..."
$match = [regex]::Match($file.Name, '^(?<name>.+)-(?<version>\d+\.\d+\.\d+)\.tgz$')
if (-not $match.Success) {
Write-Host "Unable to parse package name/version from $($file.Name), skipping."
continue
}
$pkgName = $match.Groups['name'].Value
# Normalize scope for npm lookups: "devolutions-foo" => "@devolutions/foo"
if ($pkgName -like 'devolutions-*') {
$scopedName = "@devolutions/$($pkgName.Substring(12))"
} else {
$scopedName = $pkgName
}
$pkgVersion = $match.Groups['version'].Value
# Check if this version exists on npm; exit code 0 means it does.
npm view "$scopedName@$pkgVersion" | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host "$scopedName@$pkgVersion already exists on npm; skipping publish."
continue
}
$publishCmd = @('npm','publish',"$file",'--access=public')
if ($isDryRun) {
$publishCmd += '--dry-run'
}
$publishCmd = $publishCmd -Join ' '
Invoke-Expression $publishCmd
}
shell: pwsh
- name: Create version tags
if: ${{ needs.preflight.outputs.dry-run == 'false' }}
run: |
set -e
git fetch --tags
for file in npm-packages/*.tgz; do
base=$(basename "$file" .tgz)
# Split base at the last hyphen to separate name and version
pkg=${base%-*}
# Strip the unscoped prefix introduced by `npm pack` for @devolutions/<pkg>.
pkg=${pkg#devolutions-}
version=${base##*-}
tag="npm-${pkg}-v${version}"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists; skipping."
continue
fi
git tag "$tag" "$GITHUB_SHA"
git push origin "$tag"
done
shell: bash
env:
GIT_AUTHOR_NAME: github-actions
GIT_AUTHOR_EMAIL: github-actions@github.com
GIT_COMMITTER_NAME: github-actions
GIT_COMMITTER_EMAIL: github-actions@github.com
- name: Update Artifactory Cache
if: ${{ needs.preflight.outputs.dry-run == 'false' }}
run: |
gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="iron-remote-desktop"
gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="iron-remote-desktop-rdp"
env:
GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }}
notify:
name: Notify failure
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
needs: [preflight, build]
runs-on: ubuntu-latest
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
steps:
- name: Send slack notification
id: slack
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled build for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>"
}
}
]
}

View file

@ -1,420 +0,0 @@
name: Publish NuGet package
on:
workflow_dispatch:
inputs:
dry-run:
description: 'Dry run'
required: true
type: boolean
default: true
schedule:
- cron: '21 3 * * 1' # 3:21 AM UTC every Monday
jobs:
preflight:
name: Preflight
runs-on: ubuntu-latest
outputs:
dry-run: ${{ steps.get-dry-run.outputs.dry-run }}
project-version: ${{ steps.get-version.outputs.project-version }}
package-version: ${{ steps.get-version.outputs.package-version }}
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v4
- name: Get dry run
id: get-dry-run
run: |
$IsDryRun = '${{ github.event.inputs.dry-run }}' -Eq 'true' -Or '${{ github.event_name }}' -Eq 'schedule'
if ($IsDryRun) {
echo "dry-run=true" >> $Env:GITHUB_OUTPUT
} else {
echo "dry-run=false" >> $Env:GITHUB_OUTPUT
}
shell: pwsh
- name: Get version
id: get-version
run: |
$CsprojXml = [Xml] (Get-Content .\ffi\dotnet\Devolutions.IronRdp\Devolutions.IronRdp.csproj)
$ProjectVersion = $CsprojXml.Project.PropertyGroup.Version | Select-Object -First 1
$PackageVersion = $ProjectVersion -Replace "^(\d+)\.(\d+)\.(\d+).(\d+)$", "`$1.`$2.`$3"
echo "project-version=$ProjectVersion" >> $Env:GITHUB_OUTPUT
echo "package-version=$PackageVersion" >> $Env:GITHUB_OUTPUT
shell: pwsh
build-native:
name: Native build
needs: [preflight]
runs-on: ${{matrix.runner}}
strategy:
fail-fast: false
matrix:
os: [win, osx, linux, ios, android]
arch: [x86, x64, arm, arm64]
include:
- os: win
runner: windows-2022
- os: osx
runner: macos-14
- os: linux
runner: ubuntu-22.04
- os: ios
runner: macos-14
- os: android
runner: ubuntu-22.04
exclude:
- arch: arm
os: win
- arch: arm
os: osx
- arch: arm
os: linux
- arch: arm
os: ios
- arch: x86
os: win
- arch: x86
os: osx
- arch: x86
os: linux
- arch: x86
os: ios
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v4
- name: Configure Android NDK
if: ${{ matrix.os == 'android' }}
uses: Devolutions/actions-public/cargo-android-ndk@v1
with:
android_api_level: "21"
- name: Configure macOS deployement target
if: ${{ matrix.os == 'osx' }}
run: Write-Output "MACOSX_DEPLOYMENT_TARGET=10.10" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
shell: pwsh
- name: Configure iOS deployement target
if: ${{ matrix.os == 'ios' }}
run: Write-Output "IPHONEOS_DEPLOYMENT_TARGET=12.1" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
shell: pwsh
- name: Update runner
if: ${{ matrix.os == 'linux' }}
run: sudo apt update
- name: Install dependencies for rustls
if: ${{ runner.os == 'Windows' }}
run: |
choco install ninja nasm
# We need to add the NASM binary folder to the PATH manually.
# We don't need to do that for ninja.
Write-Output "PATH=$Env:PATH;$Env:ProgramFiles\NASM" >> $Env:GITHUB_ENV
# libclang / LLVM is a requirement for AWS LC.
# https://aws.github.io/aws-lc-rs/requirements/windows.html#libclang--llvm
$VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath)
Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" >> $Env:GITHUB_ENV
# Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell
Install-Module VsDevShell -Force
shell: pwsh
# No pre-generated bindings for Android and iOS.
# https://aws.github.io/aws-lc-rs/platform_support.html#pre-generated-bindings
- name: Install bindgen-cli for aws-lc-sys
if: ${{ matrix.os == 'android' || matrix.os == 'ios' }}
run: cargo install --force --locked bindgen-cli
# For aws-lc-sys. Error returned otherwise:
# > Unable to generate bindings: ClangDiagnostic("/usr/include/stdint.h:26:10: fatal error: 'bits/libc-header-start.h' file not found\n")
- name: Install gcc-multilib
if: ${{ matrix.os == 'android' }}
run: |
sudo apt-get update
sudo apt-get install gcc-multilib
- name: Setup LLVM
if: ${{ matrix.os == 'linux' }}
uses: Devolutions/actions-public/setup-llvm@v1
with:
version: "18.1.8"
- name: Setup CBake
if: ${{ matrix.os == 'linux' }}
uses: Devolutions/actions-public/setup-cbake@v1
with:
cargo_env_scripts: true
- name: Build native lib (${{matrix.os}}-${{matrix.arch}})
run: |
$DotNetOs = '${{matrix.os}}'
$DotNetArch = '${{matrix.arch}}'
$DotNetRid = '${{matrix.os}}-${{matrix.arch}}'
$RustArch = @{'x64'='x86_64';'arm64'='aarch64';
'x86'='i686';'arm'='armv7'}[$DotNetArch]
$RustPlatform = @{'win'='pc-windows-msvc';
'osx'='apple-darwin';'ios'='apple-ios';
'linux'='unknown-linux-gnu';'android'='linux-android'}[$DotNetOs]
$LibPrefix = @{'win'='';'osx'='lib';'ios'='lib';
'linux'='lib';'android'='lib'}[$DotNetOs]
$LibSuffix = @{'win'='.dll';'osx'='.dylib';'ios'='.dylib';
'linux'='.so';'android'='.so'}[$DotNetOs]
$RustTarget = "$RustArch-$RustPlatform"
if (($DotNetOs -eq 'android') -and ($DotNetArch -eq 'arm')) {
$RustTarget = "armv7-linux-androideabi"
}
rustup target add $RustTarget
if ($DotNetOs -eq 'win') {
$Env:RUSTFLAGS="-C target-feature=+crt-static"
}
$ProjectVersion = '${{ needs.preflight.outputs.project-version }}'
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$CargoToml = Get-Content .\ffi\Cargo.toml
$CargoToml = $CargoToml | ForEach-Object {
if ($_.StartsWith("version =")) { "version = `"$PackageVersion`"" } else { $_ }
}
Set-Content -Path .\ffi\Cargo.toml -Value $CargoToml
if ($DotNetOs -eq 'linux') {
$LinuxArch = @{'x64'='amd64';'arm64'='arm64'}[$DotNetArch]
$Env:SYSROOT_NAME = "ubuntu-20.04-$LinuxArch"
. "$HOME/.cargo/cbake/${RustTarget}-enter.ps1"
$Env:AWS_LC_SYS_CMAKE_BUILDER="true"
}
$CargoParams = @(
"build",
"-p", "ffi",
"--profile", "production-ffi",
"--target", "$RustTarget"
)
& cargo $CargoParams
$OutputLibraryName = "${LibPrefix}ironrdp$LibSuffix"
$RenamedLibraryName = "${LibPrefix}DevolutionsIronRdp$LibSuffix"
$OutputLibrary = Join-Path "target" $RustTarget 'production-ffi' $OutputLibraryName
$OutputPath = Join-Path "dependencies" "runtimes" $DotNetRid "native"
New-Item -ItemType Directory -Path $OutputPath | Out-Null
Copy-Item $OutputLibrary $(Join-Path $OutputPath $RenamedLibraryName)
shell: pwsh
- name: Upload native components
uses: actions/upload-artifact@v4
with:
name: ironrdp-${{matrix.os}}-${{matrix.arch}}
path: dependencies/runtimes/${{matrix.os}}-${{matrix.arch}}
build-universal:
name: Universal build
needs: [preflight, build-native]
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
os: [ osx, ios ]
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v4
- name: Setup CCTools
uses: Devolutions/actions-public/setup-cctools@v1
- name: Download native components
uses: actions/download-artifact@v4
with:
path: dependencies/runtimes
- name: Lipo native components
run: |
Set-Location "dependencies/runtimes"
# No RID for universal binaries, see: https://github.com/dotnet/runtime/issues/53156
$OutputPath = Join-Path "${{ matrix.os }}-universal" "native"
New-Item -ItemType Directory -Path $OutputPath | Out-Null
$Libraries = Get-ChildItem -Recurse -Path "ironrdp-${{ matrix.os }}-*" -Filter "*.dylib" | Foreach-Object { $_.FullName } | Select -Unique
$LipoCmd = $(@('lipo', '-create', '-output', (Join-Path -Path $OutputPath -ChildPath "libDevolutionsIronRdp.dylib")) + $Libraries) -Join ' '
Write-Host $LipoCmd
Invoke-Expression $LipoCmd
shell: pwsh
- name: Framework
if: ${{ matrix.os == 'ios' }}
run: |
$Version = '${{ needs.preflight.outputs.project-version }}'
$ShortVersion = '${{ needs.preflight.outputs.package-version }}'
$BundleName = "libDevolutionsIronRdp"
$RuntimesDir = Join-Path "dependencies" "runtimes" "ios-universal" "native"
$FrameworkDir = Join-Path "$RuntimesDir" "$BundleName.framework"
New-Item -Path $FrameworkDir -ItemType "directory" -Force
$FrameworkExecutable = Join-Path $FrameworkDir $BundleName
Copy-Item -Path (Join-Path "$RuntimesDir" "$BundleName.dylib") -Destination $FrameworkExecutable -Force
$RPathCmd = $(@('install_name_tool', '-id', "@rpath/$BundleName.framework/$BundleName", "$FrameworkExecutable")) -Join ' '
Write-Host $RPathCmd
Invoke-Expression $RPathCmd
[xml] $InfoPlistXml = Get-Content (Join-Path "ffi" "dotnet" "Devolutions.IronRdp" "Info.plist")
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleIdentifier']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = "com.devolutions.ironrdp"
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleExecutable']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $BundleName
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleVersion']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $Version
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleShortVersionString']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $ShortVersion
}
# Write the plist *without* a BOM
$Encoding = New-Object System.Text.UTF8Encoding($false)
$Writer = New-Object System.IO.StreamWriter((Join-Path $FrameworkDir "Info.plist"), $false, $Encoding)
$InfoPlistXml.Save($Writer)
$Writer.Close()
# .NET XML document inserts two square brackets at the end of the DOCTYPE tag
# It's perfectly valid XML, but we're dealing with plists here and dyld will not be able to read the file
((Get-Content -Path (Join-Path $FrameworkDir "Info.plist") -Raw) -Replace 'PropertyList-1.0.dtd"\[\]', 'PropertyList-1.0.dtd"') | Set-Content -Path (Join-Path $FrameworkDir "Info.plist")
shell: pwsh
- name: Upload native components
uses: actions/upload-artifact@v4
with:
name: ironrdp-${{ matrix.os }}-universal
path: dependencies/runtimes/${{ matrix.os }}-universal
build-managed:
name: Managed build
needs: [build-universal]
runs-on: windows-2022
steps:
- name: Check out ${{ github.repository }}
uses: actions/checkout@v4
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Install ios workload
run: dotnet workload install ios
- name: Prepare dependencies
run: |
New-Item -ItemType Directory -Path "dependencies/runtimes" | Out-Null
shell: pwsh
- name: Download native components
uses: actions/download-artifact@v4
with:
path: dependencies/runtimes
- name: Rename dependencies
run: |
Set-Location "dependencies/runtimes"
$(Get-Item ".\ironrdp-*") | ForEach-Object { Rename-Item $_ $_.Name.Replace("ironrdp-", "") }
Get-ChildItem * -Recurse
shell: pwsh
- name: Build Devolutions.IronRdp (managed)
run: |
# net8.0 target packaged as Devolutions.IronRdp
dotnet build .\ffi\dotnet\Devolutions.IronRdp\Devolutions.IronRdp.csproj -c Release
# net9.0-ios target packaged as Devolutions.IronRdp.iOS
dotnet build .\ffi\dotnet\Devolutions.IronRdp\Devolutions.IronRdp.csproj -c Release /p:PackageId=Devolutions.IronRdp.iOS
shell: pwsh
- name: Upload managed components
uses: actions/upload-artifact@v4
with:
name: ironrdp-nupkg
path: ffi/dotnet/Devolutions.IronRdp/bin/Release/*.nupkg
publish:
name: Publish NuGet package
environment: nuget-publish
if: ${{ needs.preflight.outputs.dry-run == 'false' }}
needs: [preflight, build-managed]
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Download NuGet package artifact
uses: actions/download-artifact@v4
with:
name: ironrdp-nupkg
path: package
- name: NuGet login (OIDC)
uses: NuGet/login@v1
id: nuget-login
with:
user: ${{ secrets.NUGET_BOT_USERNAME }}
- name: Publish to nuget.org
run: |
$Files = Get-ChildItem -Recurse package/*.nupkg
foreach ($File in $Files) {
$PushCmd = @(
'dotnet',
'nuget',
'push',
"$File",
'--api-key',
'${{ steps.nuget-login.outputs.NUGET_API_KEY }}',
'--source',
'https://api.nuget.org/v3/index.json',
'--skip-duplicate'
)
Write-Host "Publishing $($File.Name)..."
$PushCmd = $PushCmd -Join ' '
Invoke-Expression $PushCmd
}
shell: pwsh
notify:
name: Notify failure
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
needs: [preflight, build-native, build-universal, build-managed]
runs-on: ubuntu-latest
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
steps:
- name: Send slack notification
id: slack
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled build for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>"
}
}
]
}

View file

@ -1,86 +0,0 @@
name: Release crates
permissions:
pull-requests: write
contents: write
on:
workflow_dispatch:
push:
branches:
- master
jobs:
# Create a PR with the new versions and changelog, preparing the next release.
open-pr:
name: Open release PR
environment: cratesio-publish
runs-on: ubuntu-latest
concurrency:
group: release-plz-${{ github.ref }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 512
- name: Run release-plz
id: release-plz
uses: Devolutions/actions-public/release-plz@v1
with:
command: release-pr
git-name: Devolutions Bot
git-email: bot@devolutions.net
github-token: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }}
- name: Update fuzz/Cargo.lock
if: ${{ steps.release-plz.outputs.did-open-pr == 'true' }}
run: |
$prRaw = '${{ steps.release-plz.outputs.pr }}'
Write-Host "prRaw: $prRaw"
$pr = $prRaw | ConvertFrom-Json
Write-Host "pr: $pr"
Write-Host "Fetch branch $($pr.head_branch)"
git fetch origin "$($pr.head_branch)"
Write-Host "Switch to branch $($pr.head_branch)"
git checkout "$($pr.head_branch)"
Write-Host "Update ./fuzz/Cargo.lock"
cargo update --manifest-path ./fuzz/Cargo.toml
Write-Host "Update last commit"
git add ./fuzz/Cargo.lock
git commit --amend --no-edit
Write-Host "Update the release pull request"
git push --force
shell: pwsh
# Release unpublished packages.
release:
name: Release crates
environment: cratesio-publish
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 512
- name: Authenticate with crates.io
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Run release-plz
uses: Devolutions/actions-public/release-plz@v1
with:
command: release
registry-token: ${{ steps.auth.outputs.token }}

24
.gitignore vendored
View file

@ -1,22 +1,2 @@
# Build artifacts
/target
/dependencies
# Local cargo root
/.cargo/local_root
# Log files
*.log
# Coverage
/docs/coverage
# Editor/IDE files
*~
/tags
.idea
.vscode
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sw?
Cargo.lock
target/

View file

@ -1,345 +0,0 @@
# Architecture
This document describes the high-level architecture of IronRDP.
> Roughly, it takes 2x more time to write a patch if you are unfamiliar with the
> project, but it takes 10x more time to figure out where you should change the
> code.
[Source](https://matklad.github.io/2021/02/06/ARCHITECTURE.md.html)
## Code Map
This section talks briefly about various important directories and data structures.
Note also which crates are **API Boundaries**.
Remember, [rules at the boundary are different](https://www.tedinski.com/2018/02/06/system-boundaries.html).
### Core Tier
Set of foundational libraries for which strict quality standards must be observed.
Note that all crates in this tier are **API Boundaries**.
Pay attention to the "**Architecture Invariant**" sections.
**Architectural Invariant**: doing I/O is not allowed for these crates.
**Architectural Invariant**: all these crates must be fuzzed.
**Architectural Invariant**: must be `#[no_std]`-compatible (optionally using the `alloc` crate). Usage of the standard
library must be opt-in through a feature flag called `std` that is enabled by default. When the `alloc` crate is optional,
a feature flag called `alloc` must exist to enable its use.
**Architectural Invariant**: no platform-dependant code (`#[cfg(windows)]` and such).
**Architectural Invariant**: no non-essential dependency is allowed.
**Architectural Invariant**: no proc-macro dependency. Dependencies such as `syn` should be pushed
as far as possible from the foundational crates so it doesnt become too much of a compilation
bottleneck. [Compilation time is a multiplier for everything][why-care-about-build-time].
The paper [Developer Productivity For Humans, Part 4: Build Latency, Predictability,
and Developer Productivity][developer-productivity] by Ciera Jaspan and Collin Green, Google
researchers, also elaborates on why it is important to keep build times low.
**Architectural Invariant**: unless the performance, usability or ergonomic gain is really worth
it, the amount of [monomorphization] incurred in downstream user code should be minimal to avoid
binary bloating and to keep the compilation as parallel as possible. Large generic functions should
be avoided if possible.
[why-care-about-build-time]: https://matklad.github.io/2021/09/04/fast-rust-builds.html#Why-Care-About-Build-Times
[developer-productivity]: https://www.computer.org/csdl/magazine/so/2023/04/10176199/1OAJyfknInm
[monomorphization]: https://rustc-dev-guide.rust-lang.org/backend/monomorph.html
#### [`crates/ironrdp`](./crates/ironrdp)
Meta crate re-exporting important crates.
**Architectural Invariant**: this crate re-exports other crates and does not provide anything else.
#### [`crates/ironrdp-core`](./crates/ironrdp-core)
Common traits and types.
This crate is motivated by the fact that only a few items are required to build most of the other crates such as the virtual channels.
To move up these crates up in the compilation tree, `ironrdp-core` must remain small, with very few dependencies.
It contains the most "low-context" building blocks.
Most notable traits are `Decode` and `Encode` which are used to define a common interface for PDU encoding and decoding.
These are object-safe, and must remain so.
Most notable types are `ReadCursor`, `WriteCursor` and `WriteBuf` which are used pervasively for encoding and decoding in a `no-std` manner.
#### [`crates/ironrdp-pdu`](./crates/ironrdp-pdu)
PDU encoding and decoding.
_TODO_: clean up the dependencies
#### [`crates/ironrdp-graphics`](./crates/ironrdp-graphics)
Image processing primitives.
_TODO_: break down into multiple smaller crates
_TODO_: clean up the dependencies
#### [`crates/ironrdp-svc`](./crates/ironrdp-svc)
Traits to implement RDP static virtual channels.
#### [`crates/ironrdp-dvc`](./crates/ironrdp-dvc)
DRDYNVC static channel implementation and traits to implement dynamic virtual channels.
#### [`crates/ironrdp-cliprdr`](./crates/ironrdp-cliprdr)
CLIPRDR static channel for clipboard implemented as described in MS-RDPECLIP.
#### [`crates/ironrdp-rdpdr`](./crates/ironrdp-rdpdr)
RDPDR channel implementation.
#### [`crates/ironrdp-rdpsnd`](./crates/ironrdp-rdpsnd)
RDPSND static channel for audio output implemented as described in MS-RDPEA.
#### [`crates/ironrdp-connector`](./crates/ironrdp-connector)
State machines to drive an RDP connection sequence.
#### [`crates/ironrdp-session`](./crates/ironrdp-session)
State machines to drive an RDP session.
#### [`crates/ironrdp-input`](./crates/ironrdp-input)
Utilities to manage and build input packets.
#### [`crates/ironrdp-rdcleanpath`](./crates/ironrdp-rdcleanpath)
RDCleanPath PDU structure used by IronRDP web client and Devolutions Gateway.
#### [`crates/ironrdp-error`](./crates/ironrdp-error)
Lightweight and `no_std`-compatible generic `Error` and `Report` types.
The `Error` type wraps a custom consumer-defined type for domain-specific details (such as `PduErrorKind`).
#### [`crates/ironrdp-propertyset`](./crates/ironrdp-propertyset)
The main type is `PropertySet`, a key-value store for configuration options.
#### [`crates/ironrdp-rdpfile`](./crates/ironrdp-rdpfile)
Loader and writer for the .RDPfile format.
### Extra Tier
Higher level libraries and binaries built on top of the core tier.
Guidelines and constraints are relaxed to some extent.
#### [`crates/ironrdp-blocking`](./crates/ironrdp-blocking)
Blocking I/O abstraction wrapping the state machines conveniently.
This crate is an **API Boundary**.
#### [`crates/ironrdp-async`](./crates/ironrdp-async)
Provides `Future`s wrapping the state machines conveniently.
This crate is an **API Boundary**.
#### [`crates/ironrdp-tokio`](./crates/ironrdp-tokio)
`Framed*` traits implementation above `tokio`s traits.
This crate is an **API Boundary**.
#### [`crates/ironrdp-futures`](./crates/ironrdp-futures)
`Framed*` traits implementation above `futures`s traits.
This crate is an **API Boundary**.
#### [`crates/ironrdp-tls`](./crates/ironrdp-tls)
TLS boilerplate common with most IronRDP clients.
NOTE: its not yet clear if this crate is an API Boundary or an implementation detail for the native clients.
#### [`crates/ironrdp-client`](./crates/ironrdp-client)
Portable RDP client without GPU acceleration.
#### [`crates/ironrdp-web`](./crates/ironrdp-web)
WebAssembly high-level bindings targeting web browsers.
This crate is an **API Boundary** (WASM module).
#### [`web-client/iron-remote-desktop`](./web-client/iron-remote-desktop)
Core frontend UI used by `iron-svelte-client` as a Web Component.
This crate is an **API Boundary**.
#### [`web-client/iron-remote-desktop-rdp`](./web-client/iron-remote-desktop-rdp)
Implementation of the TypeScript interfaces exposed by WebAssembly bindings from `ironrdp-web` and used by `iron-svelte-client`.
This crate is an **API Boundary**.
#### [`web-client/iron-svelte-client`](./web-client/iron-svelte-client)
Web-based frontend using `Svelte` and `Material` frameworks.
#### [`crates/ironrdp-cliprdr-native`](./crates/ironrdp-cliprdr-native)
Native CLIPRDR backend implementations.
#### [`crates/ironrdp-cfg`](./crates/ironrdp-cfg)
IronRDP-related utilities for ironrdp-propertyset.
### Internal Tier
Crates that are only used inside the IronRDP project, not meant to be published.
This is mostly test case generators, fuzzing oracles, build tools, and so on.
**Architecture Invariant**: these crates are not, and will never be, an **API Boundary**.
#### [`crates/ironrdp-pdu-generators`](./crates/ironrdp-pdu-generators)
`proptest` generators for `ironrdp-pdu` types.
#### [`crates/ironrdp-session-generators`](./crates/ironrdp-session-generators)
`proptest` generators for `ironrdp-session` types.
#### [`crates/ironrdp-testsuite-core`](./crates/ironrdp-testsuite-core)
Contains all integration tests for code living in the core tier, in a single binary, organized in modules.
**Architectural Invariant**: no dependency from another tier is allowed. It must be the case that
compiling and running the core test suite does not require building any library from the extra tier.
This is to keep iteration time short.
#### [`crates/ironrdp-testsuite-extra`](./crates/ironrdp-testsuite-extra)
Contains all integration tests for code living in the extra tier, in a single binary, organized in modules.
#### [`crates/ironrdp-fuzzing`](./crates/ironrdp-fuzzing)
Provides test case generators and oracles for use with fuzzing.
#### [`fuzz`](./fuzz)
Fuzz targets for code in core tier.
#### [`xtask`](./xtask)
IronRDPs free-form automation using Rust code.
### Community Tier
Crates provided and maintained by the community. Core maintainers will not invest a lot of time into
these. One or several community maintainers are associated to each one.
The IronRDP team is happy to accept new crates but may not necessarily commit to keeping them
working when changing foundational libraries. We promise to notify you if such a crate breaks, and
will always try to fix things when it's a minor change.
#### [`crates/ironrdp-acceptor`](./crates/ironrdp-acceptor) (@mihneabuz)
State machines to drive an RDP connection acceptance sequence
#### [`crates/ironrdp-server`](./crates/ironrdp-server) (@mihneabuz)
Extendable skeleton for implementing custom RDP servers.
#### [`crates/ironrdp-mstsgu`](./crates/ironrdp-mstsgu) (@steffengy)
Terminal Services Gateway Server Protocol implementation.
#### [`crates/ironrdp-glutin-renderer`](./crates/ironrdp-glutin-renderer) (no maintainer)
`glutin` primitives for OpenGL rendering.
#### [`crates/ironrdp-client-glutin`](./crates/ironrdp-client-glutin) (no maintainer)
GPU-accelerated RDP client using glutin.
#### [`crates/ironrdp-replay-client`](./crates/ironrdp-replay-client) (no maintainer)
Utility tool to replay RDP graphics pipeline for debugging purposes.
## Cross-Cutting Concerns
This section talks about the things which are everywhere and nowhere in particular.
### General
- Dependency injection when runtime information is necessary in core tier crates (no system call such as `gethostname`)
- Keep non-portable code out of core tier crates
- Make crate `no_std`-compatible wherever possible
- Facilitate fuzzing
- In libraries, provide concrete error types either hand-crafted or using `thiserror` crate
- In binaries, use the convenient catch-all error type `anyhow::Error`
- Free-form automation a-la `make` following [`cargo xtask`](https://github.com/matklad/cargo-xtask) specification
### Avoid I/O wherever possible
**Architecture Invariant**: core tier crates must never interact with the outside world. Only extra tier crates
such as `ironrdp-client`, `ironrdp-web` or `ironrdp-async` are allowed to do I/O.
### Continuous integration
We use GitHub action and our workflows simply run `cargo xtask`.
The expectation is that, if `cargo xtask ci` passes locally, the CI will be green as well.
**Architecture Invariant**: `cargo xtask ci` and CI workflow must be logically equivalents. It must
be the case that a successful `cargo xtask ci` run implies a successful CI workflow run and vice versa.
### Testing
#### Test at the boundaries (test features, not code)
We should focus on testing the public API of libraries (keyword: **API boundary**).
Thats why most (if not all) tests should go into the `ironrdp-testsuite-core` and `ironrdp-testsuite-extra` crates.
#### Do not depend on external resources
**Architecture Invariant**: tests do not depend on any kind of external resources, they are perfectly reproducible.
#### Fuzzing
See [`fuzz/README.md`](./fuzz/README.md).
#### Readability
Do not include huge binary chunks directly in source files (`*.rs`). Place these in separate files (`*.bin`, `*.bmp`)
and include them using macros such as `include_bytes!` or `include_str!`.
#### Use `expect-test` for snapshot testing
When comparing structured data (e.g.: error results, decoded PDUs), use `expect-test`. It is both easy to create
and maintain such tests. When something affecting the representation is changed, simply run the test again with
`UPDATE_EXPECT=1` env variable to magically update the code.
See:
- <https://matklad.github.io/2021/05/31/how-to-test.html#Expect-Tests>
- <https://docs.rs/expect-test/latest/expect_test/>
TODO: take further inspiration from rust-analyzer
- https://github.com/rust-lang/rust-analyzer/blob/d7c99931d05e3723d878bea5dc26766791fa4e69/docs/dev/architecture.md#testing
- https://matklad.github.io/2021/05/31/how-to-test.html
#### Use `rstest` for fixture-based testing
When a test can be generalized for multiple inputs, use [`rstest`](https://github.com/la10736/rstest) to avoid code duplication.
#### Use `proptest` for property testing
It allows to test that certain properties of your code hold for arbitrary inputs, and if a failure
is found, automatically finds the minimal test case to reproduce the problem.

7207
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,213 +1,6 @@
[workspace]
members = [
"crates/*",
"benches",
"xtask",
"ffi",
]
resolver = "2"
# FIXME: fix compilation
exclude = [
"crates/ironrdp-client-glutin",
"crates/ironrdp-glutin-renderer",
"crates/ironrdp-replay-client",
"ironrdp",
"ironrdp_client"
]
[workspace.package]
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/Devolutions/IronRDP"
repository = "https://github.com/Devolutions/IronRDP"
authors = ["Devolutions Inc. <infos@devolutions.net>", "Teleport <goteleport.com>"]
keywords = ["rdp", "remote-desktop", "network", "client", "protocol"]
categories = ["network-programming"]
[workspace.dependencies]
# Note that for better cross-tooling interactions, do not use workspace
# dependencies for anything that is not "workspace internal" (e.g.: mostly
# dev-dependencies). E.g.: release-plz cant detect that a dependency has been
# updated in a way warranting a version bump in the dependant if no commit is
# touching a file associated to the crate. It is technically okay to use that
# for "private" (i.e.: not used in the public API) dependencies too, but we
# still want to make follow-up releases to stay up to date with the community,
# even for private dependencies.
expect-test = "1"
proptest = "1.4"
rstest = "0.26"
# Note: we are trying to move away from using these crates.
# They are being kept around for now for legacy compatibility,
# but new usage should be avoided.
num-derive = "0.4"
num-traits = "0.2"
[workspace.lints.rust]
# == Safer unsafe == #
unsafe_op_in_unsafe_fn = "warn"
invalid_reference_casting = "warn"
unused_unsafe = "warn"
missing_unsafe_on_extern = "warn"
unsafe_attr_outside_unsafe = "warn"
# == Correctness == #
ambiguous_negative_literals = "warn"
keyword_idents_2024 = "warn" # FIXME: remove when switched to 2024 edition
# == Style, readability == #
elided_lifetimes_in_paths = "warn" # https://quinedot.github.io/rust-learning/dont-hide.html
absolute_paths_not_starting_with_crate = "warn"
single_use_lifetimes = "warn"
unreachable_pub = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
keyword_idents = "warn"
noop_method_call = "warn"
macro_use_extern_crate = "warn"
redundant_imports = "warn"
redundant_lifetimes = "warn"
trivial_numeric_casts = "warn"
# missing_docs = "warn" # TODO: NOTE(@CBenoit): we probably want to ensure this in core tier crates only
# == Compile-time / optimization == #
unused_crate_dependencies = "warn"
unused_macro_rules = "warn"
# == Extra-pedantic rustc == #
unit_bindings = "warn"
[workspace.lints.clippy]
# == Safer unsafe == #
undocumented_unsafe_blocks = "warn"
unnecessary_safety_comment = "warn"
multiple_unsafe_ops_per_block = "warn"
missing_safety_doc = "warn"
transmute_ptr_to_ptr = "warn"
as_ptr_cast_mut = "warn"
as_pointer_underscore = "warn"
cast_ptr_alignment = "warn"
fn_to_numeric_cast_any = "warn"
ptr_cast_constness = "warn"
# == Correctness == #
as_conversions = "warn"
cast_lossless = "warn"
cast_possible_truncation = "warn"
cast_possible_wrap = "warn"
cast_sign_loss = "warn"
filetype_is_file = "warn"
float_cmp = "warn"
lossy_float_literal = "warn"
float_cmp_const = "warn"
as_underscore = "warn"
unwrap_used = "warn"
large_stack_frames = "warn"
mem_forget = "warn"
mixed_read_write_in_expression = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
panic = "warn"
precedence_bits = "warn"
rc_mutex = "warn"
same_name_method = "warn"
string_slice = "warn"
suspicious_xor_used_as_pow = "warn"
unused_result_ok = "warn"
missing_panics_doc = "warn"
# == Style, readability == #
semicolon_outside_block = "warn" # With semicolon-outside-block-ignore-multiline = true
clone_on_ref_ptr = "warn"
cloned_instead_of_copied = "warn"
pub_without_shorthand = "warn"
infinite_loop = "warn"
empty_enum_variants_with_brackets = "warn"
deref_by_slicing = "warn"
multiple_inherent_impl = "warn"
map_with_unused_argument_over_ranges = "warn"
partial_pub_fields = "warn"
trait_duplication_in_bounds = "warn"
type_repetition_in_bounds = "warn"
checked_conversions = "warn"
get_unwrap = "warn"
similar_names = "warn" # Reduce risk of confusing similar names together, and protects against typos when variable shadowing was intended.
str_to_string = "warn"
string_to_string = "warn"
std_instead_of_core = "warn"
separated_literal_suffix = "warn"
unused_self = "warn"
useless_let_if_seq = "warn"
string_add = "warn"
range_plus_one = "warn"
self_named_module_files = "warn"
# TODO: partial_pub_fields = "warn" (should we enable only in pdu crates?)
redundant_type_annotations = "warn"
unnecessary_self_imports = "warn"
try_err = "warn"
rest_pat_in_fully_bound_structs = "warn"
# == Compile-time / optimization == #
doc_include_without_cfg = "warn"
inline_always = "warn"
large_include_file = "warn"
or_fun_call = "warn"
rc_buffer = "warn"
string_lit_chars_any = "warn"
unnecessary_box_returns = "warn"
large_futures = "warn"
# == Extra-pedantic clippy == #
allow_attributes = "warn"
cfg_not_test = "warn"
disallowed_script_idents = "warn"
non_zero_suggestions = "warn"
renamed_function_params = "warn"
unused_trait_names = "warn"
collection_is_never_read = "warn"
copy_iterator = "warn"
expl_impl_clone_on_copy = "warn"
implicit_clone = "warn"
large_types_passed_by_value = "warn"
redundant_clone = "warn"
alloc_instead_of_core = "warn"
empty_drop = "warn"
return_self_not_must_use = "warn"
wildcard_dependencies = "warn"
wildcard_imports = "warn"
# == Lets not merge unintended eprint!/print! statements in libraries == #
print_stderr = "warn"
print_stdout = "warn"
dbg_macro = "warn"
todo = "warn"
[profile.dev]
opt-level = 1
[profile.production]
inherits = "release"
lto = true
[profile.production-ffi]
inherits = "release"
strip = "symbols"
codegen-units = 1
lto = true
[profile.production-wasm]
inherits = "release"
opt-level = "s"
lto = true
[profile.test.package.proptest]
opt-level = 3
[profile.test.package.rand_chacha]
opt-level = 3
[patch.crates-io]
# FIXME: We need to catch up with Diplomat upstream again, but this is a significant amount of work.
# In the meantime, we use this forked version which fixes an undefined behavior in the code expanded by the bridge macro.
diplomat = { git = "https://github.com/CBenoit/diplomat", rev = "6dc806e80162b6b39509a04a2835744236cd2396" }

View file

@ -1,74 +1,4 @@
# IronRDP
[![](https://docs.rs/ironrdp/badge.svg)](https://docs.rs/ironrdp/) [![](https://img.shields.io/crates/v/ironrdp)](https://crates.io/crates/ironrdp)
A Rust implementation of the Microsoft Remote Desktop Protocol, with a focus on security.
A collection of Rust crates providing an implementation of the Microsoft Remote Desktop Protocol, with a focus on security.
## Demonstration
<https://user-images.githubusercontent.com/3809077/202049929-76f42471-aeb0-41da-9118-0dc6ea491bd2.mp4>
## Video Codec Support
Supported codecs:
- Uncompressed raw bitmap
- Interleaved Run-Length Encoding (RLE) Bitmap Codec
- RDP 6.0 Bitmap Compression
- Microsoft RemoteFX (RFX)
## Examples
### [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client)
A full-fledged RDP client based on IronRDP crates suite, and implemented using non-blocking, asynchronous I/O.
```shell
cargo run --bin ironrdp-client -- <HOSTNAME> --username <USERNAME> --password <PASSWORD>
```
### [`screenshot`](https://github.com/Devolutions/IronRDP/blob/master/crates/ironrdp/examples/screenshot.rs)
Example of utilizing IronRDP in a blocking, synchronous fashion.
This example showcases the use of IronRDP in a blocking manner. It
demonstrates how to create a basic RDP client with just a few hundred lines
of code by leveraging the IronRDP crates suite.
In this basic client implementation, the client establishes a connection
with the destination server, decodes incoming graphics updates, and saves the
resulting output as a BMP image file on the disk.
```shell
cargo run --example=screenshot -- --host <HOSTNAME> --username <USERNAME> --password <PASSWORD> --output out.bmp
```
### How to enable RemoteFX on server
Run the following PowerShell commands, and reboot.
```pwsh
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows NT\Terminal Services' -Name 'ColorDepth' -Type DWORD -Value 5
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows NT\Terminal Services' -Name 'fEnableVirtualizedGraphics' -Type DWORD -Value 1
```
Alternatively, you may change a few group policies using `gpedit.msc`:
1. Run `gpedit.msc`.
2. Enable `Computer Configuration/Administrative Templates/Windows Components/Remote Desktop Services/Remote Desktop Session Host/Remote Session Environment/RemoteFX for Windows Server 2008 R2/Configure RemoteFX`
3. Enable `Computer Configuration/Administrative Templates/Windows Components/Remote Desktop Services/Remote Desktop Session Host/Remote Session Environment/Enable RemoteFX encoding for RemoteFX clients designed for Windows Server 2008 R2 SP1`
4. Enable `Computer Configuration/Administrative Templates/Windows Components/Remote Desktop Services/Remote Desktop Session Host/Remote Session Environment/Limit maximum color depth`
5. Reboot.
## Architecture
See the [ARCHITECTURE.md](https://github.com/Devolutions/IronRDP/blob/master/ARCHITECTURE.md) document.
## Getting help
- Report bugs in the [issue tracker](https://github.com/Devolutions/IronRDP/issues)
- Discuss the project on the [matrix room](https://matrix.to/#/#IronRDP:matrix.org)

623
STYLE.md
View file

@ -1,623 +0,0 @@
Our approach to "clean code" is two-fold:
- we avoid blocking PRs on style changes, but
- at the same time, the codebase is constantly refactored.
It is explicitly OK for a reviewer to flag only some nits in the PR, and then send a follow-up cleanup PR for things which are easier to explain by example, cc'ing the original author.
Sending small cleanup PRs (like renaming a single local variable) is encouraged.
These PRs are easy to merge and very welcomed.
When reviewing pull requests prefer extending this document to leaving non-reusable comments on the pull request itself.
# Style
## Formatting for sizes / lengths (e.g.: in `Encode::size()` and `FIXED_PART_SIZE` definitions)
Use an inline comment for each field of the structure.
```rust
// GOOD
const FIXED_PART_SIZE: usize = 1 /* Version */ + 1 /* Endianness */ + 2 /* CommonHeaderLength */ + 4 /* Filler */;
// GOOD
const FIXED_PART_SIZE: usize = 1 // Version
+ 1 // Endianness
+ 2 // CommonHeaderLength
+ 4; // Filler
// GOOD
fn size(&self) -> usize {
4 // ReturnCode
+ 4 // cBytes
+ self.reader_names.size() // mszReaderNames
+ 4 // dwState
+ 4 // dwProtocol
+ self.atr.len() // pbAtr
+ 4 // cbAtrLen
}
// BAD
const FIXED_PART_SIZE: usize = 1 + 1 + 2 + 4;
// BAD
const FIXED_PART_SIZE: usize = size_of::<u8>() + size_of::<u8>() + size_of::<u16>() + size_of::<u32>();
// BAD
fn size(&self) -> usize {
size_of::<u32>() * 5 + self.reader_names.size() + self.atr.len()
}
```
**Rationale**: boring and readable, having a comment with the name of the field is useful when following along the documentation.
Here is an excerpt illustrating this:
![Documentation excerpt](https://user-images.githubusercontent.com/3809077/272724889-681a83c9-aa83-4f48-85f4-0721c3148508.png)
`size_of::<u8>()` by itself is not really more useful than writing `1` directly.
The size of `u8` is not going to change, and its not hard to predict.
The struct also does not necessarily directly hold a `u8` as-is, and it may be hard to correlate a wrapper type with the corresponding `size_of::<u8>()`.
The memory representation of the wrapper type may differ from its network representation, so its not possible to always replace with `size_of::<Wrapper>()` instead.
## Error handling
### Return type
Use `crate_name::Result` (e.g.: `anyhow::Result`) rather than just `Result`.
**Rationale:** makes it immediately clear what result that is.
Exception: its not necessary when the type alias is clear enough (e.g.: `ConnectionResult`).
### Formatting of error messages
A single sentence which:
- is short and concise,
- does not start by a capital letter, and
- does not contain trailing punctuation.
This is the convention adopted by the Rust project:
- [Rust API Guidelines][api-guidelines-errors]
- [std::error::Error][std-error-trait]
Also, use proper abbreviation casing, e.g., IPv4 and IPv6 (not ipv4/ipv6).
```rust
// GOOD
"invalid X.509 certificate"
// BAD
"Invalid X.509 certificate."
```
**Rationale**: its easier to compose with other error messages.
To illustrate with terminal error reports:
```
// GOOD
Error: invalid server license, caused by invalid X.509 certificate, caused by unexpected ASN.1 DER tag: expected SEQUENCE, got CONTEXT-SPECIFIC [19] (primitive)
// BAD
Error: Invalid server license., Caused by Invalid X.509 certificate., Caused by Unexpected ASN.1 DER tag: expected SEQUENCE, got CONTEXT-SPECIFIC [19] (primitive)
```
The error reporter (e.g.: `ironrdp_error::ErrorReport`) is responsible for adding the punctuation and/or capitalizing the text down the line.
[api-guidelines-errors]: https://rust-lang.github.io/api-guidelines/interoperability.html#error-types-are-meaningful-and-well-behaved-c-good-err
[std-error-trait]: https://doc.rust-lang.org/stable/std/error/trait.Error.html
## Logging
If any, the human-readable message should start with a capital letter and not end with a period.
```rust
// GOOD
info!("Connect to RDP host");
// BAD
info!("connect to RDP host.");
```
**Rationale**: consistency.
Log messages are typically not composed together like error messages, so its fine to start with a capital letter.
Use tracing ability to [record structured fields][tracing-fields].
```rust
// GOOD
info!(%server_addr, "Looked up server address");
// BAD
info!("Looked up server address: {server_addr}");
```
**Rationale**: structured diagnostic information is tracings strength.
Its possible to retrieve the records emitted by tracing in a structured manner.
Name fields after what already exist consistently as much as possible.
For example, errors are typically recorded as fields named `error`.
```rust
// GOOD
error!(?error, "Active stage failed");
error!(error = ?e, "Active stage failed");
error!(%error, "Active stage failed");
error!(error = format!("{err:#}"), "Active stage failed");
// BAD
error!(?e, "Active stage failed");
error!(%err, "Active stage failed");
```
**Rationale**: consistency.
We can rely on this to filter and collect diagnostics.
[tracing-fields]: https://docs.rs/tracing/latest/tracing/index.html#recording-fields
## Helper functions
Avoid creating single-use helper functions:
```rust
// GOOD
let buf = {
let mut buf = WriteBuf::new();
buf.write_u32(42);
buf
};
// BAD
let buf = prepare_buf(42);
// Somewhere else
fn prepare_buf(value: u32) -> WriteBuf {
let mut buf = WriteBuf::new();
buf.write_u32(value);
buf
}
```
**Rationale:** single-use functions change frequently, adding or removing parameters adds churn.
A block serves just as well to delineate a bit of logic, but has access to all the context.
Re-using originally single-purpose function often leads to bad coupling.
Exception: if you want to make use of `return` or `?`.
## Local helper functions
Put nested helper functions at the end of the enclosing functions (this requires using return statement).
Don't nest more than one level deep.
```rust
// GOOD
fn func() -> u32 {
return helper();
fn helper() -> u32 {
/* ... */
}
}
// BAD
fn func() -> u32 {
fn helper() -> u32 {
/* ... */
}
helper()
}
```
**Rationale:** consistency, improved top-down readability.
## Documentation
### Doc comments should link to reference documents
Add links to specification and/or other relevant documents in doc comments.
Include verbatim the name of the section or the description of the item from the specification.
Use reference-style links for readability.
Do not make the link too long.
```rust
// GOOD
/// [2.2.3.3.8] Server Drive Query Information Request (DR_DRIVE_QUERY_INFORMATION_REQ)
///
/// The server issues a query information request on a redirected file system device.
///
/// [2.2.3.3.8]: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/e43dcd68-2980-40a9-9238-344b6cf94946
pub struct ServerDriveQueryInformationRequest {
/* snip */
}
// BAD (no doc comment)
pub struct ServerDriveQueryInformationRequest {
/* snip */
}
// BAD (non reference-style links make barely readable, very long lines)
/// [2.2.3.3.8](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/e43dcd68-2980-40a9-9238-344b6cf94946) Server Drive Query Information Request (DR_DRIVE_QUERY_INFORMATION_REQ)
///
/// The server issues a query information request on a redirected file system device.
pub struct ServerDriveQueryInformationRequest {
/* snip */
}
// BAD (long link)
/// [2.2.3.3.8 Server Drive Query Information Request (DR_DRIVE_QUERY_INFORMATION_REQ)]
///
/// The server issues a query information request on a redirected file system device.
///
/// [2.2.3.3.8 Server Drive Query Information Request (DR_DRIVE_QUERY_INFORMATION_REQ)]: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/e43dcd68-2980-40a9-9238-344b6cf94946
pub struct ServerDriveQueryInformationRequest {
/* snip */
}
```
**Rationale**: consistency.
Easy cross-referencing between code and reference documents.
### Inline code comments are proper sentences
Style inline code comments as proper sentences.
Start with a capital letter, end with a dot.
```rust
// GOOD
// When building a library, `-` in the artifact name are replaced by `_`.
let artifact_name = format!("{}.wasm", package.replace('-', "_"));
// BAD
// when building a library, `-` in the artifact name are replaced by `_`
let artifact_name = format!("{}.wasm", package.replace('-', "_"));
```
**Rationale:** writing a sentence (or maybe even a paragraph) rather just "a comment" creates a more appropriate frame of mind.
It tricks you into writing down more of the context you keep in your head while coding.
Exception: no period for brief comments (e.g., `// VER`, `// RSV`, `// ATYP`)
### "Sentence per line" style
For `.md` and `.adoc` files, prefer a sentence-per-line format, don't wrap lines.
If the line is too long, you want to split the sentence in two.
**Rationale:** much easier to edit the text and read the diff, see [this link][asciidoctor-practices].
[asciidoctor-practices]: https://asciidoctor.org/docs/asciidoc-recommended-practices/#one-sentence-per-line
## Invariants
Recommended reads:
- <https://en.wikipedia.org/wiki/Invariant_(mathematics)#Invariants_in_computer_science>
- <https://en.wikipedia.org/wiki/Loop_invariant>
- <https://en.wikipedia.org/wiki/Class_invariant>
- <https://matklad.github.io/2023/10/06/what-is-an-invariant.html>
- <https://matklad.github.io/2023/09/13/comparative-analysis.html>
### Write down invariants clearly
Write down invariants using `INVARIANT:` code comments.
```rust
// GOOD
// INVARIANT: for i in 0..lo: xs[i] < x
// BAD
// for i in 0..lo: xs[i] < x
```
**Rationale**: invariants should be upheld at all times.
Its useful to keep invariants in mind when analyzing the flow of the code.
Its easy to look up the local invariants when programming "in the small".
For field invariants, a doc comment should come at the place where they are declared, inside the type definition.
```rust
// GOOD
struct BitmapInfoHeader {
/// INVARIANT: `width.abs() <= u16::MAX`
width: i32,
}
// BAD
/// INVARIANT: `width.abs() <= u16::MAX`
struct BitmapInfoHeader {
width: i32,
}
// BAD
struct BitmapInfoHeader {
width: i32,
}
impl BitmapInfoHeader {
fn new(width: i32) -> Option<BitmapInfoHeader> {
// INVARIANT: width.abs() <= u16::MAX
if !(width.abs() <= i32::from(u16::MAX)) {
return None;
}
Some(BitmapInfoHeader { width })
}
}
```
**Rationale**: its easy to find about the invariant.
The invariant will show up in the documentation (typically available by hovering the item in IDEs).
For loop invariants, the comment should come before or at the beginning of the loop.
```rust
// GOOD
/// Computes the smallest index such that, if `x` is inserted at this index, the array remains sorted.
fn insertion_point(xs: &[i32], x: i32) -> usize {
let mut lo = 0;
let mut hi = xs.len();
while lo < hi {
// INVARIANT: for i in 0..lo: xs[i] < x
// INVARIANT: for i in hi..: x <= xs[i]
let mid = lo + (hi - lo) / 2;
if xs[mid] < x {
lo = mid + 1;
} else {
hi = mid;
}
}
lo
}
// BAD
fn insertion_point(xs: &[i32], x: i32) -> usize {
let mut lo = 0;
let mut hi = xs.len();
while lo < hi {
let mid = lo + (hi - lo) / 2;
if xs[mid] < x {
lo = mid + 1;
} else {
hi = mid;
}
}
// INVARIANT: for i in 0..lo: xs[i] < x
// INVARIANT: for i in hi..: x <= xs[i]
lo
}
```
**Rationale**: improved top-down readability, only read forward, no need to backtrack.
For function output invariants, the comment should be specified in the doc comment.
(However, consider [enforcing this invariant][parse-dont-validate] using [the type system][type-safety] instead.)
```rust
// GOOD
/// Computes the stride of an uncompressed RGB bitmap.
///
/// INVARIANT: `width <= output (stride) <= width * 4`
fn rgb_bmp_stride(width: u16, bit_count: u16) -> usize {
assert!(bit_count <= 32);
let stride = /* ... */;
stride
}
// BAD
/// Computes the stride of an uncompressed RGB bitmap.
fn rgb_bmp_stride(width: u16, bit_count: u16) -> usize {
assert!(bit_count <= 32);
// INVARIANT: width <= stride <= width * 4
let stride = /* ... */;
stride
}
```
**Rationale**: its easy to find about the invariant.
The invariant will show up in the documentation (typically available by hovering the item in IDEs).
[parse-dont-validate]: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
[type-safety]: https://www.parsonsmatt.org/2017/10/11/type_safety_back_and_forth.html
### Explain non-obvious assumptions by referencing the invariants
Explain clearly non-obvious assumptions and invariants relied upon (e.g.: when disabling a lint locally).
When referencing invariants, do not use the `INVARIANT:` comment prefix which is reserved for defining them.
```rust
// GOOD
// Per invariants: width * dst_n_samples <= 10_000 * 4 < usize::MAX
#[allow(clippy::arithmetic_side_effects)]
let dst_stride = usize::from(width) * dst_n_samples;
// BAD
#[allow(clippy::arithmetic_side_effects)]
let dst_stride = usize::from(width) * dst_n_samples;
// BAD
// INVARIANT: width * dst_n_samples <= 10_000 * 4 < usize::MAX
#[allow(clippy::arithmetic_side_effects)]
let dst_stride = usize::from(width) * dst_n_samples;
```
**Rationale**: make the assumption obvious.
The code is easier to review.
No one will lose time refactoring based on the wrong assumption.
### State invariants positively
Establish invariants positively.
Prefer `if !invariant` to `if negated_invariant`.
```rust
// GOOD
if !(idx < len) {
return None;
}
// GOOD
check_invariant(idx < len)?;
// GOOD
ensure!(idx < len);
// GOOD
debug_assert!(idx < len);
// GOOD
if idx < len {
/* ... */
} else {
return None;
}
// BAD
if idx >= len {
return None;
}
```
**Rationale:** it's useful to see the invariant relied upon by the rest of the function clearly spelled out.
### Strongly prefer `<` and `<=` over `>` and `>=`
Use `<` and `<=` operators instead of `>` and `>=`.
```rust
/// GOOD
if lo <= x && x <= hi {}
if x < lo || hi < x {}
/// BAD
if x >= lo && x <= hi {}
if x < lo || x > hi {}
```
**Rationale**: consistent, canonicalized form that is trivial to visualize by reading from left to right.
Things are naturally ordered from small to big like in the [number line].
[number line]: https://en.wikipedia.org/wiki/Number_line
## Context parameters
Some parameters are threaded unchanged through many function calls.
They determine the "context" of the operation.
Pass such parameters first, not last.
If there are several context parameters, consider [packing them into a `struct Ctx` and passing it as `&self`][ra-ctx-struct].
```rust
// GOOD
fn do_something(connector: &mut ClientConnector, certificate: &[u8]) {
let public_key = extract_public_key(certificate);
do_something_else(connector, public_key, |kind| /* … */);
}
fn do_something_else(connector: &mut ClientConnector, public_key: &[u8], op: impl Fn(KeyKind) -> bool) {
/* ... */
}
// BAD
fn do_something(certificate: &[u8], connector: &mut ClientConnector) {
let public_key = extract_public_key(certificate);
do_something_else(|kind| /* … */, connector, public_key);
}
fn do_something_else(op: impl Fn(KeyKind) -> bool, connector: &mut ClientConnector, public_key: &[u8]) {
/* ... */
}
```
**Rationale:** consistency.
Context-first works better when non-context parameter is a lambda.
[ra-ctx-struct]: https://github.com/rust-lang/rust-analyzer/blob/76633199f4316b9c659d4ec0c102774d693cd940/crates/ide-db/src/path_transform.rs#L192-L339
# Runtime and compile time performance
## Avoid allocations
Avoid writing code which is slower than it needs to be.
Don't allocate a `Vec` where an iterator would do, don't allocate strings needlessly.
```rust
// GOOD
let second_word = text.split(' ').nth(1)?;
// BAD
let words: Vec<&str> = text.split(' ').collect();
let second_word = words.get(1)?;
```
**Rationale:** not allocating is almost always faster.
## Push allocations to the call site
If allocation is inevitable, let the caller allocate the resource:
```rust
// GOOD
fn frobnicate(s: String) {
/* snip */
}
// BAD
fn frobnicate(s: &str) {
let s = s.to_string();
/* snip */
}
```
**Rationale:** reveals the costs.
It is also more efficient when the caller already owns the allocation.
## Avoid monomorphization
Avoid making a lot of code type parametric, *especially* on the boundaries between crates.
```rust
// GOOD
fn frobnicate(f: impl FnMut()) {
frobnicate_impl(&mut f)
}
fn frobnicate_impl(f: &mut dyn FnMut()) {
/* lots of code */
}
// BAD
fn frobnicate(f: impl FnMut()) {
/* lots of code */
}
```
Avoid `AsRef` polymorphism, it pays back only for widely used libraries:
```rust
// GOOD
fn frobnicate(f: &Path) { }
// BAD
fn frobnicate(f: impl AsRef<Path>) { }
```
**Rationale:** Rust uses monomorphization to compile generic code, meaning that for each instantiation of a generic functions with concrete types, the function is compiled afresh, *per crate*.
This allows for fantastic performance, but leads to increased compile times.
Runtime performance obeys the 80/20 rule (Pareto Principle) — only a small fraction of code is hot.
Compile time **does not** obey this rule — all code has to be compiled.

View file

@ -1,32 +0,0 @@
[package]
name = "benches"
version = "0.0.0"
description = "IronRDP benchmarks"
publish = false
edition.workspace = true
[[bin]]
name = "perfenc"
path = "src/perfenc.rs"
[features]
default = ["qoi", "qoiz"]
qoi = ["ironrdp/qoi"]
qoiz = ["ironrdp/qoiz"]
[dependencies]
anyhow = "1.0.99"
async-trait = "0.1.89"
bytesize = "2.3"
ironrdp = { path = "../crates/ironrdp", features = [
"server",
"pdu",
"__bench",
] }
pico-args = "0.5.0"
tokio = { version = "1", features = ["sync", "fs", "time"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing = { version = "0.1", features = ["log"] }
[lints]
workspace = true

View file

@ -1,210 +0,0 @@
#![allow(unused_crate_dependencies)] // False positives because there are both a library and a binary.
#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
use core::num::{NonZeroU16, NonZeroUsize};
use core::time::Duration;
use std::io::Write as _;
use std::time::Instant;
use anyhow::Context as _;
use ironrdp::pdu::rdp::capability_sets::{CmdFlags, EntropyBits};
use ironrdp::server::bench::encoder::{UpdateEncoder, UpdateEncoderCodecs};
use ironrdp::server::{BitmapUpdate, DesktopSize, DisplayUpdate, PixelFormat, RdpServerDisplayUpdates};
use tokio::fs::File;
use tokio::io::AsyncReadExt as _;
use tokio::time::sleep;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), anyhow::Error> {
setup_logging()?;
let mut args = pico_args::Arguments::from_env();
if args.contains(["-h", "--help"]) {
println!("Usage: perfenc [OPTIONS] <RGBX_INPUT_FILENAME>");
println!();
println!("Measure the performance of the IronRDP server encoder, given a raw RGBX video input file.");
println!();
println!("Options:");
println!(" --width <WIDTH> Width of the display (default: 3840)");
println!(" --height <HEIGHT> Height of the display (default: 2400)");
println!(" --codec <CODEC> Codec to use (default: remotefx)");
println!(" Valid values: qoi, qoiz, remotefx, bitmap, none");
println!(" --fps <FPS> Frames per second (default: none)");
std::process::exit(0);
}
let width = args.opt_value_from_str("--width")?.unwrap_or(3840);
let height = args.opt_value_from_str("--height")?.unwrap_or(2400);
let codec = args.opt_value_from_str("--codec")?.unwrap_or_else(OptCodec::default);
let fps = args.opt_value_from_str("--fps")?.unwrap_or(0);
let filename: String = args.free_from_str().context("missing RGBX input filename")?;
let file = File::open(&filename)
.await
.with_context(|| format!("Failed to open file: {filename}"))?;
let mut flags = CmdFlags::all();
let mut update_codecs = UpdateEncoderCodecs::new();
match codec {
OptCodec::RemoteFX => update_codecs.set_remotefx(Some((EntropyBits::Rlgr3, 0))),
OptCodec::Bitmap => {
flags -= CmdFlags::SET_SURFACE_BITS;
}
OptCodec::None => {}
#[cfg(feature = "qoi")]
OptCodec::Qoi => update_codecs.set_qoi(Some(0)),
#[cfg(feature = "qoiz")]
OptCodec::QoiZ => update_codecs.set_qoiz(Some(0)),
};
let mut encoder = UpdateEncoder::new(DesktopSize { width, height }, flags, update_codecs)
.context("failed to initialize update encoder")?;
let mut total_raw = 0u64;
let mut total_enc = 0u64;
let mut n_updates = 0u64;
let mut updates = DisplayUpdates::new(file, DesktopSize { width, height }, fps);
while let Some(up) = updates.next_update().await? {
if let DisplayUpdate::Bitmap(ref up) = up {
total_raw += u64::try_from(up.data.len())?;
} else {
eprintln!("Invalid update");
break;
}
let mut iter = encoder.update(up);
loop {
let Some(frag) = iter.next().await else {
break;
};
let len = u64::try_from(frag?.data.len())?;
total_enc += len;
}
n_updates += 1;
print!(".");
std::io::stdout().flush()?;
}
println!();
#[expect(clippy::as_conversions, reason = "casting u64 to f64")]
let ratio = total_enc as f64 / total_raw as f64;
let percent = 100.0 - ratio * 100.0;
println!("Encoder: {encoder:?}");
println!("Nb updates: {n_updates:?}");
println!(
"Sum of bytes: {}/{} ({:.2}%)",
bytesize::ByteSize(total_enc),
bytesize::ByteSize(total_raw),
percent,
);
Ok(())
}
struct DisplayUpdates {
file: File,
desktop_size: DesktopSize,
fps: u64,
last_update_time: Option<Instant>,
}
impl DisplayUpdates {
fn new(file: File, desktop_size: DesktopSize, fps: u64) -> Self {
Self {
file,
desktop_size,
fps,
last_update_time: None,
}
}
}
#[async_trait::async_trait]
impl RdpServerDisplayUpdates for DisplayUpdates {
async fn next_update(&mut self) -> anyhow::Result<Option<DisplayUpdate>> {
let stride = self.desktop_size.width as usize * 4;
let frame_size = stride * self.desktop_size.height as usize;
let mut buf = vec![0u8; frame_size];
// FIXME: AsyncReadExt::read_exact is not cancellation safe.
self.file.read_exact(&mut buf).await.context("read exact")?;
let now = Instant::now();
if let Some(last_update_time) = self.last_update_time {
let elapsed = now - last_update_time;
if self.fps > 0 && elapsed < Duration::from_millis(1000 / self.fps) {
sleep(Duration::from_millis(
1000 / self.fps
- u64::try_from(elapsed.as_millis())
.context("invalid `elapsed millis`: out of range integral conversion")?,
))
.await;
}
}
self.last_update_time = Some(now);
let up = DisplayUpdate::Bitmap(BitmapUpdate {
x: 0,
y: 0,
width: NonZeroU16::new(self.desktop_size.width).context("width cannot be zero")?,
height: NonZeroU16::new(self.desktop_size.height).context("height cannot be zero")?,
format: PixelFormat::RgbX32,
data: buf.into(),
stride: NonZeroUsize::new(stride).context("stride cannot be zero")?,
});
Ok(Some(up))
}
}
fn setup_logging() -> anyhow::Result<()> {
use tracing::metadata::LevelFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
let fmt_layer = tracing_subscriber::fmt::layer().compact();
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.with_env_var("IRONRDP_LOG")
.from_env_lossy();
tracing_subscriber::registry()
.with(fmt_layer)
.with(env_filter)
.try_init()
.context("failed to set tracing global subscriber")?;
Ok(())
}
enum OptCodec {
RemoteFX,
Bitmap,
None,
#[cfg(feature = "qoi")]
Qoi,
#[cfg(feature = "qoiz")]
QoiZ,
}
impl Default for OptCodec {
fn default() -> Self {
Self::RemoteFX
}
}
impl core::str::FromStr for OptCodec {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"remotefx" => Ok(Self::RemoteFX),
"bitmap" => Ok(Self::Bitmap),
"none" => Ok(Self::None),
#[cfg(feature = "qoi")]
"qoi" => Ok(Self::Qoi),
#[cfg(feature = "qoiz")]
"qoiz" => Ok(Self::QoiZ),
_ => anyhow::bail!("unknown codec: {s}"),
}
}
}

13
ci/build.sh Normal file
View file

@ -0,0 +1,13 @@
set -ex
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build
cargo build --release
cargo build --all --exclude=ironrdp_client --target wasm32-unknown-unknown
cargo build --all --exclude=ironrdp_client --target wasm32-unknown-unknown --release
cargo test
cargo test --release

View file

@ -1,94 +0,0 @@
# Configuration file for git-cliff
[changelog]
trim = false
header = """
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
"""
# https://tera.netlify.app/docs/#introduction
body = """
{% if version -%}
## [[{{ version | trim_start_matches(pat="v") }}]{%- if release_link -%}({{ release_link }}){% endif %}] - {{ timestamp | date(format="%Y-%m-%d") }}
{%- else -%}
## [Unreleased]
{%- endif %}
{% for group, commits in commits | group_by(attribute="group") -%}
### {{ group | upper_first }}
{%- for commit in commits %}
{%- set message = commit.message | upper_first %}
{%- if commit.breaking %}
{%- set breaking = "[**breaking**] " %}
{%- else %}
{%- set breaking = "" %}
{%- endif %}
{%- set short_sha = commit.id | truncate(length=10, end="") %}
{%- set commit_url = "https://github.com/Devolutions/IronRDP/commit/" ~ commit.id %}
{%- set commit_link = "[" ~ short_sha ~ "](" ~ commit_url ~ ")" %}
- {{ breaking }}{{ message }} ({{ commit_link }}) \
{% if commit.body %}\n\n {{ commit.body | replace(from="\n", to="\n ") }}{% endif %}
{%- endfor %}
{% endfor -%}
"""
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
filter_commits = false
date_order = false
protect_breaking_commits = true
sort_commits = "oldest"
commit_preprocessors = [
# Replace the issue number with the link.
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://github.com/Devolutions/IronRDP/issues/${1}))" },
# Replace commit sha1 with the link.
{ pattern = '([a-f0-9]{10})([a-f0-9]{30})', replace = "[${0}](https://github.com/Devolutions/IronRDP/commit/${1}${2})" },
]
# regex for parsing and grouping commits
# <!-- <NUMBER> --> is a trick to control the section order: https://github.com/orhun/git-cliff/issues/9#issuecomment-914521594
commit_parsers = [
{ message = "^chore", skip = true },
{ message = "^style", skip = true },
{ message = "^refactor", skip = true },
{ message = "^test", skip = true },
{ message = "^ci", skip = true },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ footer = "^[Cc]hangelog: ?ignore", skip = true },
{ message = "(?i)security", group = "<!-- 0 -->Security" },
{ body = "(?i)security", group = "<!-- 0 -->Security" },
{ footer = "^[Ss]ecurity: ?yes", group = "<!-- 0 -->Security" },
{ message = "^feat", group = "<!-- 1 -->Features" },
{ message = "^revert", group = "<!-- 3 -->Revert" },
{ message = "^fix", group = "<!-- 4 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 5 -->Performance" },
{ message = "^doc", group = "<!-- 6 -->Documentation" },
{ message = "^build", group = "<!-- 7 -->Build" },
{ message = "(?i)improve", group = "<!-- 2 -->Improvements" },
{ message = "(?i)adjust", group = "<!-- 2 -->Improvements" },
{ message = "(?i)change", group = "<!-- 2 -->Improvements" },
{ message = ".*", group = "<!-- 99 -->Please Sort" },
]

View file

@ -1,6 +0,0 @@
msrv = "1.87"
semicolon-outside-block-ignore-multiline = true
accept-comment-above-statement = true
accept-comment-above-attributes = true
allow-panic-in-tests = true
allow-unwrap-in-tests = true

View file

@ -1,39 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.6.0...iron-remote-desktop-v0.7.0)] - 2025-09-29
### <!-- 4 -->Bug Fixes
- [**breaking**] Changed onClipboardChanged to not consume the input (#992) ([6127e13c83](https://github.com/Devolutions/IronRDP/commit/6127e13c836d06764d483b6b55188fd23a4314a2))
## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.5.0...iron-remote-desktop-v0.6.0)] - 2025-08-29
### <!-- 1 -->Features
- [**breaking**] Extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels (#952) ([23c0cc2c36](https://github.com/Devolutions/IronRDP/commit/23c0cc2c365159d24330a89ec4015121b67bccb6))
## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.4.0...iron-remote-desktop-v0.5.0)] - 2025-08-29
### <!-- 4 -->Bug Fixes
- [**breaking**] Remove the `remote_received_format_list_callback` method from Session common API (#935) ([5b948e2161](https://github.com/Devolutions/IronRDP/commit/5b948e2161b08b13d32bdbb480b26c8fa44d42f7))
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.3.0...iron-remote-desktop-v0.4.0)] - 2025-06-27
### <!-- 1 -->Features
- [**breaking**] Add `canvas_resized_callback` method to `SessionBuilder` trait (#842) ([f6285c5989](https://github.com/Devolutions/IronRDP/commit/f6285c598915c8afb07553c765648d85ac4140cb))
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.2.0...iron-remote-desktop-v0.3.0)] - 2025-06-03
### <!-- 4 -->Bug Fixes
- [**breaking**] Rename extension_call to invoke_extension (#803) ([f68cd06ac3](https://github.com/Devolutions/IronRDP/commit/f68cd06ac3705608e6f2ac6bde684d9ae906ea53))

View file

@ -1,34 +0,0 @@
[package]
name = "iron-remote-desktop"
version = "0.7.0"
readme = "README.md"
description = "Helper crate for building WASM modules compatible with iron-remote-desktop WebComponent"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[features]
panic_hook = ["dep:console_error_panic_hook"]
[dependencies]
# WASM
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["HtmlCanvasElement"] }
tracing-web = "0.1"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1", optional = true }
# Logging
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["time"] }
[lints]
workspace = true

View file

@ -1,8 +0,0 @@
# Iron Remote Desktop — Helper Crate
Helper crate for building WASM modules compatible with the `iron-remote-desktop` WebComponent.
Implement the `RemoteDesktopApi` trait on a Rust type, and call the `make_bridge!` on
it to generate the WASM API that is expected by `iron-remote-desktop`.
See the `ironrdp-web` crate in the repository to see how it is used in practice.

View file

@ -1,23 +0,0 @@
use wasm_bindgen::JsValue;
pub trait ClipboardData {
type Item: ClipboardItem;
fn create() -> Self;
fn add_text(&mut self, mime_type: &str, text: &str);
fn add_binary(&mut self, mime_type: &str, binary: &[u8]);
fn items(&self) -> &[Self::Item];
fn is_empty(&self) -> bool {
self.items().is_empty()
}
}
pub trait ClipboardItem {
fn mime_type(&self) -> &str;
fn value(&self) -> impl Into<JsValue>;
}

View file

@ -1,10 +0,0 @@
#[derive(Debug)]
pub enum CursorStyle {
Default,
Hidden,
Url {
data: String,
hotspot_x: u16,
hotspot_y: u16,
},
}

View file

@ -1,16 +0,0 @@
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
#[derive(Clone, Copy)]
pub struct DesktopSize {
pub width: u16,
pub height: u16,
}
#[wasm_bindgen]
impl DesktopSize {
#[wasm_bindgen(constructor)]
pub fn create(width: u16, height: u16) -> Self {
DesktopSize { width, height }
}
}

View file

@ -1,26 +0,0 @@
use wasm_bindgen::prelude::*;
pub trait IronError {
fn backtrace(&self) -> String;
fn kind(&self) -> IronErrorKind;
}
#[derive(Clone, Copy)]
#[wasm_bindgen]
pub enum IronErrorKind {
/// Catch-all error kind
General,
/// Incorrect password used
WrongPassword,
/// Unable to login to machine
LogonFailure,
/// Insufficient permission, server denied access
AccessDenied,
/// Something wrong happened when sending or receiving the RDCleanPath message
RDCleanPath,
/// Couldnt connect to proxy
ProxyConnect,
/// Protocol negotiation failed
NegotiationFailure,
}

View file

@ -1,76 +0,0 @@
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;
#[macro_export]
macro_rules! extension_match {
( @ $jsval:expr, $value:ident, String, $operation:block ) => {{
if let Some($value) = $jsval.as_string() {
$operation
} else {
warn!("Unexpected value for extension {}", stringify!($ident));
}
}};
( @ $jsval:expr, $value:ident, f64, $operation:block ) => {{
if let Some($value) = $jsval.as_f64() {
$operation
} else {
warn!("Unexpected value for extension {}", stringify!($ident));
}
}};
( @ $jsval:expr, $value:ident, bool, $operation:block ) => {{
if let Some($value) = $jsval.as_bool() {
$operation
} else {
warn!("Unexpected value for extension {}", stringify!($ident));
}
}};
( @ $jsval:expr, $value:ident, JsValue, $operation:block ) => {{
let $value = $jsval;
$operation
}};
( match $ext:ident ; $( | $value:ident : $ty:ident | $operation:block ; )* ) => {
let ident = $ext.ident();
match ident {
$( stringify!($value) => $crate::extension_match!( @ $ext.into_value(), $value, $ty, $operation ), )*
unknown_extension => ::tracing::warn!("Unknown extension: {unknown_extension}"),
}
};
}
#[wasm_bindgen]
pub struct Extension {
ident: String,
value: JsValue,
}
#[wasm_bindgen]
impl Extension {
#[wasm_bindgen(constructor)]
pub fn create(ident: String, value: JsValue) -> Self {
Self { ident, value }
}
}
#[expect(
clippy::allow_attributes,
reason = "Unfortunately, expect attribute doesn't work with clippy::multiple_inherent_impl lint"
)]
#[allow(
clippy::multiple_inherent_impl,
reason = "We don't want to expose these methods to JS"
)]
impl Extension {
pub fn ident(&self) -> &str {
self.ident.as_str()
}
pub fn value(&self) -> &JsValue {
&self.value
}
pub fn into_value(self) -> JsValue {
self.value
}
}

View file

@ -1,34 +0,0 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub enum RotationUnit {
Pixel,
Line,
Page,
}
pub trait DeviceEvent {
fn mouse_button_pressed(button: u8) -> Self;
fn mouse_button_released(button: u8) -> Self;
fn mouse_move(x: u16, y: u16) -> Self;
fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: RotationUnit) -> Self;
fn key_pressed(scancode: u16) -> Self;
fn key_released(scancode: u16) -> Self;
fn unicode_pressed(unicode: char) -> Self;
fn unicode_released(unicode: char) -> Self;
}
pub trait InputTransaction {
type DeviceEvent: DeviceEvent;
fn create() -> Self;
fn add_event(&mut self, event: Self::DeviceEvent);
}

View file

@ -1,480 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
mod clipboard;
mod cursor;
mod desktop_size;
mod error;
mod extension;
mod input;
mod session;
pub use clipboard::{ClipboardData, ClipboardItem};
pub use cursor::CursorStyle;
pub use desktop_size::DesktopSize;
pub use error::{IronError, IronErrorKind};
pub use extension::Extension;
pub use input::{DeviceEvent, InputTransaction, RotationUnit};
pub use session::{Session, SessionBuilder, SessionTerminationInfo};
pub trait RemoteDesktopApi {
type Session: Session;
type SessionBuilder: SessionBuilder;
type SessionTerminationInfo: SessionTerminationInfo;
type DeviceEvent: DeviceEvent;
type InputTransaction: InputTransaction;
type ClipboardData: ClipboardData;
type ClipboardItem: ClipboardItem;
type Error: IronError;
/// Called before the logger is set.
fn pre_setup() {}
/// Called after the logger is set.
fn post_setup() {}
}
#[macro_export]
macro_rules! make_bridge {
($api:ty) => {
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct Session(<$api as $crate::RemoteDesktopApi>::Session);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct SessionBuilder(<$api as $crate::RemoteDesktopApi>::SessionBuilder);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct SessionTerminationInfo(<$api as $crate::RemoteDesktopApi>::SessionTerminationInfo);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct DeviceEvent(<$api as $crate::RemoteDesktopApi>::DeviceEvent);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct InputTransaction(<$api as $crate::RemoteDesktopApi>::InputTransaction);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct ClipboardData(<$api as $crate::RemoteDesktopApi>::ClipboardData);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct ClipboardItem(<$api as $crate::RemoteDesktopApi>::ClipboardItem);
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
pub struct IronError(<$api as $crate::RemoteDesktopApi>::Error);
impl From<<$api as $crate::RemoteDesktopApi>::Session> for Session {
fn from(value: <$api as $crate::RemoteDesktopApi>::Session) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::SessionBuilder> for SessionBuilder {
fn from(value: <$api as $crate::RemoteDesktopApi>::SessionBuilder) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::SessionTerminationInfo> for SessionTerminationInfo {
fn from(value: <$api as $crate::RemoteDesktopApi>::SessionTerminationInfo) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::DeviceEvent> for DeviceEvent {
fn from(value: <$api as $crate::RemoteDesktopApi>::DeviceEvent) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::InputTransaction> for InputTransaction {
fn from(value: <$api as $crate::RemoteDesktopApi>::InputTransaction) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::ClipboardData> for ClipboardData {
fn from(value: <$api as $crate::RemoteDesktopApi>::ClipboardData) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::ClipboardItem> for ClipboardItem {
fn from(value: <$api as $crate::RemoteDesktopApi>::ClipboardItem) -> Self {
Self(value)
}
}
impl From<<$api as $crate::RemoteDesktopApi>::Error> for IronError {
fn from(value: <$api as $crate::RemoteDesktopApi>::Error) -> Self {
Self(value)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
pub fn setup(log_level: &str) {
<$api as $crate::RemoteDesktopApi>::pre_setup();
$crate::internal::setup(log_level);
<$api as $crate::RemoteDesktopApi>::post_setup();
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl Session {
pub async fn run(&self) -> Result<SessionTerminationInfo, IronError> {
$crate::Session::run(&self.0)
.await
.map(SessionTerminationInfo)
.map_err(IronError)
}
#[wasm_bindgen(js_name = desktopSize)]
pub fn desktop_size(&self) -> $crate::DesktopSize {
$crate::Session::desktop_size(&self.0)
}
#[wasm_bindgen(js_name = applyInputs)]
pub fn apply_inputs(&self, transaction: InputTransaction) -> Result<(), IronError> {
$crate::Session::apply_inputs(&self.0, transaction.0).map_err(IronError)
}
#[wasm_bindgen(js_name = releaseAllInputs)]
pub fn release_all_inputs(&self) -> Result<(), IronError> {
$crate::Session::release_all_inputs(&self.0).map_err(IronError)
}
#[wasm_bindgen(js_name = synchronizeLockKeys)]
pub fn synchronize_lock_keys(
&self,
scroll_lock: bool,
num_lock: bool,
caps_lock: bool,
kana_lock: bool,
) -> Result<(), IronError> {
$crate::Session::synchronize_lock_keys(&self.0, scroll_lock, num_lock, caps_lock, kana_lock)
.map_err(IronError)
}
pub fn shutdown(&self) -> Result<(), IronError> {
$crate::Session::shutdown(&self.0).map_err(IronError)
}
#[wasm_bindgen(js_name = onClipboardPaste)]
pub async fn on_clipboard_paste(&self, content: &ClipboardData) -> Result<(), IronError> {
$crate::Session::on_clipboard_paste(&self.0, &content.0)
.await
.map_err(IronError)
}
pub fn resize(
&self,
width: u32,
height: u32,
scale_factor: Option<u32>,
physical_width: Option<u32>,
physical_height: Option<u32>,
) {
$crate::Session::resize(
&self.0,
width,
height,
scale_factor,
physical_width,
physical_height,
);
}
#[wasm_bindgen(js_name = supportsUnicodeKeyboardShortcuts)]
pub fn supports_unicode_keyboard_shortcuts(&self) -> bool {
$crate::Session::supports_unicode_keyboard_shortcuts(&self.0)
}
#[wasm_bindgen(js_name = invokeExtension)]
pub fn invoke_extension(
&self,
ext: $crate::Extension,
) -> Result<$crate::internal::wasm_bindgen::JsValue, IronError> {
<<$api as $crate::RemoteDesktopApi>::Session as $crate::Session>::invoke_extension(&self.0, ext)
.map_err(IronError)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl SessionBuilder {
#[wasm_bindgen(constructor)]
pub fn create() -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::SessionBuilder as $crate::SessionBuilder>::create())
}
pub fn username(&self, username: String) -> Self {
Self($crate::SessionBuilder::username(&self.0, username))
}
pub fn destination(&self, destination: String) -> Self {
Self($crate::SessionBuilder::destination(&self.0, destination))
}
#[wasm_bindgen(js_name = serverDomain)]
pub fn server_domain(&self, server_domain: String) -> Self {
Self($crate::SessionBuilder::server_domain(&self.0, server_domain))
}
pub fn password(&self, password: String) -> Self {
Self($crate::SessionBuilder::password(&self.0, password))
}
#[wasm_bindgen(js_name = proxyAddress)]
pub fn proxy_address(&self, address: String) -> Self {
Self($crate::SessionBuilder::proxy_address(&self.0, address))
}
#[wasm_bindgen(js_name = authToken)]
pub fn auth_token(&self, token: String) -> Self {
Self($crate::SessionBuilder::auth_token(&self.0, token))
}
#[wasm_bindgen(js_name = desktopSize)]
pub fn desktop_size(&self, desktop_size: $crate::DesktopSize) -> Self {
Self($crate::SessionBuilder::desktop_size(&self.0, desktop_size))
}
#[wasm_bindgen(js_name = renderCanvas)]
pub fn render_canvas(&self, canvas: $crate::internal::web_sys::HtmlCanvasElement) -> Self {
Self($crate::SessionBuilder::render_canvas(&self.0, canvas))
}
#[wasm_bindgen(js_name = setCursorStyleCallback)]
pub fn set_cursor_style_callback(&self, callback: $crate::internal::web_sys::js_sys::Function) -> Self {
Self($crate::SessionBuilder::set_cursor_style_callback(
&self.0, callback,
))
}
#[wasm_bindgen(js_name = setCursorStyleCallbackContext)]
pub fn set_cursor_style_callback_context(&self, context: $crate::internal::wasm_bindgen::JsValue) -> Self {
Self($crate::SessionBuilder::set_cursor_style_callback_context(
&self.0, context,
))
}
#[wasm_bindgen(js_name = remoteClipboardChangedCallback)]
pub fn remote_clipboard_changed_callback(
&self,
callback: $crate::internal::web_sys::js_sys::Function,
) -> Self {
Self($crate::SessionBuilder::remote_clipboard_changed_callback(
&self.0, callback,
))
}
#[wasm_bindgen(js_name = forceClipboardUpdateCallback)]
pub fn force_clipboard_update_callback(
&self,
callback: $crate::internal::web_sys::js_sys::Function,
) -> Self {
Self($crate::SessionBuilder::force_clipboard_update_callback(
&self.0, callback,
))
}
#[wasm_bindgen(js_name = canvasResizedCallback)]
pub fn canvas_resized_callback(&self, callback: $crate::internal::web_sys::js_sys::Function) -> Self {
Self($crate::SessionBuilder::canvas_resized_callback(&self.0, callback))
}
pub fn extension(&self, ext: $crate::Extension) -> Self {
Self($crate::SessionBuilder::extension(&self.0, ext))
}
pub async fn connect(&self) -> Result<Session, IronError> {
$crate::SessionBuilder::connect(&self.0)
.await
.map(Session)
.map_err(IronError)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl SessionTerminationInfo {
pub fn reason(&self) -> String {
$crate::SessionTerminationInfo::reason(&self.0)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl DeviceEvent {
#[wasm_bindgen(js_name = mouseButtonPressed)]
pub fn mouse_button_pressed(button: u8) -> Self {
Self(
<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::mouse_button_pressed(
button,
),
)
}
#[wasm_bindgen(js_name = mouseButtonReleased)]
pub fn mouse_button_released(button: u8) -> Self {
Self(
<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::mouse_button_released(
button,
),
)
}
#[wasm_bindgen(js_name = mouseMove)]
pub fn mouse_move(x: u16, y: u16) -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::mouse_move(x, y))
}
#[wasm_bindgen(js_name = wheelRotations)]
pub fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: $crate::RotationUnit) -> Self {
Self(
<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::wheel_rotations(
vertical,
rotation_amount,
rotation_unit,
),
)
}
#[wasm_bindgen(js_name = keyPressed)]
pub fn key_pressed(scancode: u16) -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::key_pressed(scancode))
}
#[wasm_bindgen(js_name = keyReleased)]
pub fn key_released(scancode: u16) -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::key_released(scancode))
}
#[wasm_bindgen(js_name = unicodePressed)]
pub fn unicode_pressed(unicode: char) -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::unicode_pressed(unicode))
}
#[wasm_bindgen(js_name = unicodeReleased)]
pub fn unicode_released(unicode: char) -> Self {
Self(
<<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::unicode_released(unicode),
)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl InputTransaction {
#[wasm_bindgen(constructor)]
pub fn create() -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::InputTransaction as $crate::InputTransaction>::create())
}
#[wasm_bindgen(js_name = addEvent)]
pub fn add_event(&mut self, event: DeviceEvent) {
$crate::InputTransaction::add_event(&mut self.0, event.0);
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl ClipboardData {
#[wasm_bindgen(constructor)]
pub fn create() -> Self {
Self(<<$api as $crate::RemoteDesktopApi>::ClipboardData as $crate::ClipboardData>::create())
}
#[wasm_bindgen(js_name = addText)]
pub fn add_text(&mut self, mime_type: &str, text: &str) {
$crate::ClipboardData::add_text(&mut self.0, mime_type, text);
}
#[wasm_bindgen(js_name = addBinary)]
pub fn add_binary(&mut self, mime_type: &str, binary: &[u8]) {
$crate::ClipboardData::add_binary(&mut self.0, mime_type, binary);
}
pub fn items(&self) -> Vec<ClipboardItem> {
$crate::ClipboardData::items(&self.0)
.into_iter()
.cloned()
.map(ClipboardItem)
.collect()
}
#[wasm_bindgen(js_name = isEmpty)]
pub fn is_empty(&self) -> bool {
$crate::ClipboardData::is_empty(&self.0)
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl ClipboardItem {
#[wasm_bindgen(js_name = mimeType)]
pub fn mime_type(&self) -> String {
$crate::ClipboardItem::mime_type(&self.0).to_owned()
}
pub fn value(&self) -> $crate::internal::wasm_bindgen::JsValue {
$crate::ClipboardItem::value(&self.0).into()
}
}
#[$crate::internal::wasm_bindgen::prelude::wasm_bindgen]
#[doc(hidden)]
impl IronError {
pub fn backtrace(&self) -> String {
$crate::IronError::backtrace(&self.0)
}
pub fn kind(&self) -> $crate::IronErrorKind {
$crate::IronError::kind(&self.0)
}
}
};
}
#[doc(hidden)]
pub mod internal {
#[doc(hidden)]
pub use wasm_bindgen;
#[doc(hidden)]
pub use web_sys;
#[doc(hidden)]
pub fn setup(log_level: &str) {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
#[cfg(feature = "panic_hook")]
console_error_panic_hook::set_once();
if let Ok(level) = log_level.parse::<tracing::Level>() {
set_logger_once(level);
}
}
fn set_logger_once(level: tracing::Level) {
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt::time::UtcTime;
use tracing_subscriber::prelude::*;
use tracing_web::MakeConsoleWriter;
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| {
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_timer(UtcTime::rfc_3339()) // std::time is not available in browsers
.with_writer(MakeConsoleWriter);
let level_filter = LevelFilter::from_level(level);
tracing_subscriber::registry().with(fmt_layer).with(level_filter).init();
})
}
}

View file

@ -1,106 +0,0 @@
use wasm_bindgen::JsValue;
use web_sys::{js_sys, HtmlCanvasElement};
use crate::clipboard::ClipboardData;
use crate::error::IronError;
use crate::input::InputTransaction;
use crate::{DesktopSize, Extension};
pub trait SessionBuilder {
type Session: Session;
type Error: IronError;
fn create() -> Self;
#[must_use]
fn username(&self, username: String) -> Self;
#[must_use]
fn destination(&self, destination: String) -> Self;
#[must_use]
fn server_domain(&self, server_domain: String) -> Self;
#[must_use]
fn password(&self, password: String) -> Self;
#[must_use]
fn proxy_address(&self, address: String) -> Self;
#[must_use]
fn auth_token(&self, token: String) -> Self;
#[must_use]
fn desktop_size(&self, desktop_size: DesktopSize) -> Self;
#[must_use]
fn render_canvas(&self, canvas: HtmlCanvasElement) -> Self;
#[must_use]
fn set_cursor_style_callback(&self, callback: js_sys::Function) -> Self;
#[must_use]
fn set_cursor_style_callback_context(&self, context: JsValue) -> Self;
#[must_use]
fn remote_clipboard_changed_callback(&self, callback: js_sys::Function) -> Self;
#[must_use]
fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> Self;
#[must_use]
fn canvas_resized_callback(&self, callback: js_sys::Function) -> Self;
#[must_use]
fn extension(&self, ext: Extension) -> Self;
#[expect(async_fn_in_trait)]
async fn connect(&self) -> Result<Self::Session, Self::Error>;
}
pub trait Session {
type SessionTerminationInfo: SessionTerminationInfo;
type InputTransaction: InputTransaction;
type ClipboardData: ClipboardData;
type Error: IronError;
fn run(&self) -> impl core::future::Future<Output = Result<Self::SessionTerminationInfo, Self::Error>>;
fn desktop_size(&self) -> DesktopSize;
fn apply_inputs(&self, transaction: Self::InputTransaction) -> Result<(), Self::Error>;
fn release_all_inputs(&self) -> Result<(), Self::Error>;
fn synchronize_lock_keys(
&self,
scroll_lock: bool,
num_lock: bool,
caps_lock: bool,
kana_lock: bool,
) -> Result<(), Self::Error>;
fn shutdown(&self) -> Result<(), Self::Error>;
fn on_clipboard_paste(
&self,
content: &Self::ClipboardData,
) -> impl core::future::Future<Output = Result<(), Self::Error>>;
fn resize(
&self,
width: u32,
height: u32,
scale_factor: Option<u32>,
physical_width: Option<u32>,
physical_height: Option<u32>,
);
fn supports_unicode_keyboard_shortcuts(&self) -> bool;
fn invoke_extension(&self, ext: Extension) -> Result<JsValue, Self::Error>;
}
pub trait SessionTerminationInfo {
fn reason(&self) -> String;
}

View file

@ -1,77 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.7.0...ironrdp-acceptor-v0.8.0)] - 2025-12-18
### <!-- 4 -->Bug Fixes
- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a))
- Rename `AsyncNetworkClient` to `NetworkClient`
- Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch
using generics (`&mut N where N: NetworkClient`)
- Reorder `connect_finalize` parameters for consistency across crates
## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.5.0...ironrdp-acceptor-v0.6.0)] - 2025-07-08
### <!-- 1 -->Features
- [**breaking**] Support for server-side Kerberos (#839) ([33530212c4](https://github.com/Devolutions/IronRDP/commit/33530212c42bf28c875ac078ed2408657831b417))
## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.4.0...ironrdp-acceptor-v0.5.0)] - 2025-05-27
### <!-- 1 -->Features
- Make the CredsspSequence type public ([5abd9ff8e0](https://github.com/Devolutions/IronRDP/commit/5abd9ff8e0da8ea48c6747526c4b703a39bf4972))
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.3.1...ironrdp-acceptor-v0.4.0)] - 2025-03-12
### <!-- 7 -->Build
- Bump ironrdp-pdu
## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.3.0...ironrdp-acceptor-v0.3.1)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.2.1...ironrdp-acceptor-v0.3.0)] - 2025-01-28
### <!-- 0 -->Security
- Allow using basic RDP/no security ([7c72a9f9bb](https://github.com/Devolutions/IronRDP/commit/7c72a9f9bbe726d6f9f2377c19e9a672d8d086d5))
### <!-- 4 -->Bug Fixes
- Drop unexpected PDUs during deactivation-reactivation ([63963182b5](https://github.com/Devolutions/IronRDP/commit/63963182b5af6ad45dc638e93de4b8a0b565c7d3))
The current behavior of handling unmatched PDUs in fn read_by_hint()
isn't good enough. An unexpected PDUs may be received and fail to be
decoded during Acceptor::step().
Change the code to simply drop unexpected PDUs (as opposed to attempting
to replay the unmatched leftover, which isn't clearly needed)
- Reattach existing channels ([c4587b537c](https://github.com/Devolutions/IronRDP/commit/c4587b537c7c0a148e11bc365bc3df88e2c92312))
I couldn't find any explicit behaviour described in the specification,
but apparently, we must just keep the channel state as they were during
reactivation. This fixes various state issues during client resize.
- Do not restart static channels on reactivation ([82c7c2f5b0](https://github.com/Devolutions/IronRDP/commit/82c7c2f5b08c44b1a4f6b04c13ad24d9e2ffa371))
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.2.0...ironrdp-acceptor-v0.2.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))

View file

@ -1,28 +0,0 @@
[package]
name = "ironrdp-acceptor"
version = "0.8.0"
readme = "README.md"
description = "State machines to drive an RDP connection acceptance sequence"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public
ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public
ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public
ironrdp-async = { path = "../ironrdp-async", version = "0.8" } # public
tracing = { version = "0.1", features = ["log"] }
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,9 +0,0 @@
# IronRDP Acceptor
State machines to drive an RDP connection acceptance sequence.
For now, it requires the [Tokio runtime](https://tokio.rs/).
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,198 +0,0 @@
use std::collections::HashSet;
use ironrdp_connector::{
reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written,
};
use ironrdp_core::WriteBuf;
use ironrdp_pdu::mcs;
use ironrdp_pdu::x224::X224;
use tracing::debug;
#[derive(Debug)]
pub struct ChannelConnectionSequence {
state: ChannelConnectionState,
user_channel_id: u16,
channel_ids: Option<HashSet<u16>>,
}
#[derive(Default, Debug)]
pub enum ChannelConnectionState {
#[default]
Consumed,
WaitErectDomainRequest,
WaitAttachUserRequest,
SendAttachUserConfirm,
WaitChannelJoinRequest {
remaining: HashSet<u16>,
},
SendChannelJoinConfirm {
remaining: HashSet<u16>,
channel_id: u16,
},
AllJoined,
}
impl State for ChannelConnectionState {
fn name(&self) -> &'static str {
match self {
Self::Consumed => "Consumed",
Self::WaitErectDomainRequest => "WaitErectDomainRequest",
Self::WaitAttachUserRequest => "WaitAttachUserRequest",
Self::SendAttachUserConfirm => "SendAttachUserConfirm",
Self::WaitChannelJoinRequest { .. } => "WaitChannelJoinRequest",
Self::SendChannelJoinConfirm { .. } => "SendChannelJoinConfirm",
Self::AllJoined { .. } => "AllJoined",
}
}
fn is_terminal(&self) -> bool {
matches!(self, Self::AllJoined { .. })
}
fn as_any(&self) -> &dyn core::any::Any {
self
}
}
impl Sequence for ChannelConnectionSequence {
fn next_pdu_hint(&self) -> Option<&dyn ironrdp_pdu::PduHint> {
match &self.state {
ChannelConnectionState::Consumed => None,
ChannelConnectionState::WaitErectDomainRequest => Some(&ironrdp_pdu::X224_HINT),
ChannelConnectionState::WaitAttachUserRequest => Some(&ironrdp_pdu::X224_HINT),
ChannelConnectionState::SendAttachUserConfirm => None,
ChannelConnectionState::WaitChannelJoinRequest { .. } => Some(&ironrdp_pdu::X224_HINT),
ChannelConnectionState::SendChannelJoinConfirm { .. } => None,
ChannelConnectionState::AllJoined => None,
}
}
fn state(&self) -> &dyn State {
&self.state
}
fn step(&mut self, input: &[u8], output: &mut WriteBuf) -> ConnectorResult<Written> {
let (written, next_state) = match core::mem::take(&mut self.state) {
ChannelConnectionState::WaitErectDomainRequest => {
let erect_domain_request = ironrdp_core::decode::<X224<mcs::ErectDomainPdu>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
debug!(message = ?erect_domain_request, "Received");
(Written::Nothing, ChannelConnectionState::WaitAttachUserRequest)
}
ChannelConnectionState::WaitAttachUserRequest => {
let attach_user_request = ironrdp_core::decode::<X224<mcs::AttachUserRequest>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
debug!(message = ?attach_user_request, "Received");
(Written::Nothing, ChannelConnectionState::SendAttachUserConfirm)
}
ChannelConnectionState::SendAttachUserConfirm => {
let attach_user_confirm = mcs::AttachUserConfirm {
result: 0,
initiator_id: self.user_channel_id,
};
debug!(message = ?attach_user_confirm, "Send");
let written =
ironrdp_core::encode_buf(&X224(attach_user_confirm), output).map_err(ConnectorError::encode)?;
let next_state = match self.channel_ids.take() {
Some(channel_ids) => ChannelConnectionState::WaitChannelJoinRequest { remaining: channel_ids },
None => ChannelConnectionState::AllJoined,
};
(Written::from_size(written)?, next_state)
}
ChannelConnectionState::WaitChannelJoinRequest { mut remaining } => {
let channel_request = ironrdp_core::decode::<X224<mcs::ChannelJoinRequest>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
debug!(message = ?channel_request, "Received");
let is_expected = remaining.remove(&channel_request.channel_id);
if !is_expected {
return Err(reason_err!(
"ChannelJoinConfirm",
"unexpected channel_id in MCS Channel Join Request: got {}, expected one of: {:?}",
channel_request.channel_id,
remaining,
));
}
(
Written::Nothing,
ChannelConnectionState::SendChannelJoinConfirm {
remaining,
channel_id: channel_request.channel_id,
},
)
}
ChannelConnectionState::SendChannelJoinConfirm { remaining, channel_id } => {
let channel_confirm = mcs::ChannelJoinConfirm {
result: 0,
initiator_id: self.user_channel_id,
requested_channel_id: channel_id,
channel_id,
};
debug!(message = ?channel_confirm, "Send");
let written =
ironrdp_core::encode_buf(&X224(channel_confirm), output).map_err(ConnectorError::encode)?;
let next_state = if remaining.is_empty() {
ChannelConnectionState::AllJoined
} else {
ChannelConnectionState::WaitChannelJoinRequest { remaining }
};
(Written::from_size(written)?, next_state)
}
_ => unreachable!(),
};
self.state = next_state;
Ok(written)
}
}
impl ChannelConnectionSequence {
pub fn new(user_channel_id: u16, io_channel_id: u16, other_channels: Vec<u16>) -> Self {
Self {
state: ChannelConnectionState::WaitErectDomainRequest,
user_channel_id,
channel_ids: Some(
vec![user_channel_id, io_channel_id]
.into_iter()
.chain(other_channels)
.collect(),
),
}
}
pub fn skip_channel_join(user_channel_id: u16) -> Self {
Self {
state: ChannelConnectionState::WaitErectDomainRequest,
user_channel_id,
channel_ids: None,
}
}
pub fn is_done(&self) -> bool {
self.state.is_terminal()
}
}

View file

@ -1,760 +0,0 @@
use core::mem;
use ironrdp_connector::{
encode_x224_packet, general_err, reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize,
Sequence, State, Written,
};
use ironrdp_core::{decode, WriteBuf};
use ironrdp_pdu as pdu;
use ironrdp_pdu::nego::SecurityProtocol;
use ironrdp_pdu::x224::X224;
use ironrdp_svc::{StaticChannelSet, SvcServerProcessor};
use pdu::rdp::capability_sets::CapabilitySet;
use pdu::rdp::client_info::Credentials;
use pdu::rdp::headers::ShareControlPdu;
use pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu};
use pdu::rdp::server_license::{LicensePdu, LicensingErrorMessage};
use pdu::{gcc, mcs, nego, rdp};
use tracing::{debug, warn};
use super::channel_connection::ChannelConnectionSequence;
use super::finalization::FinalizationSequence;
use crate::util::{self, wrap_share_data};
const IO_CHANNEL_ID: u16 = 1003;
const USER_CHANNEL_ID: u16 = 1002;
pub struct Acceptor {
pub(crate) state: AcceptorState,
security: SecurityProtocol,
io_channel_id: u16,
user_channel_id: u16,
desktop_size: DesktopSize,
server_capabilities: Vec<CapabilitySet>,
static_channels: StaticChannelSet,
saved_for_reactivation: AcceptorState,
pub(crate) creds: Option<Credentials>,
reactivation: bool,
}
#[derive(Debug)]
pub struct AcceptorResult {
pub static_channels: StaticChannelSet,
pub capabilities: Vec<CapabilitySet>,
pub input_events: Vec<Vec<u8>>,
pub user_channel_id: u16,
pub io_channel_id: u16,
pub reactivation: bool,
}
impl Acceptor {
pub fn new(
security: SecurityProtocol,
desktop_size: DesktopSize,
capabilities: Vec<CapabilitySet>,
creds: Option<Credentials>,
) -> Self {
Self {
security,
state: AcceptorState::InitiationWaitRequest,
user_channel_id: USER_CHANNEL_ID,
io_channel_id: IO_CHANNEL_ID,
desktop_size,
server_capabilities: capabilities,
static_channels: StaticChannelSet::new(),
saved_for_reactivation: Default::default(),
creds,
reactivation: false,
}
}
pub fn new_deactivation_reactivation(
mut consumed: Acceptor,
static_channels: StaticChannelSet,
desktop_size: DesktopSize,
) -> ConnectorResult<Self> {
let AcceptorState::CapabilitiesSendServer {
early_capability,
channels,
} = consumed.saved_for_reactivation
else {
return Err(general_err!("invalid acceptor state"));
};
for cap in consumed.server_capabilities.iter_mut() {
if let CapabilitySet::Bitmap(cap) = cap {
cap.desktop_width = desktop_size.width;
cap.desktop_height = desktop_size.height;
}
}
let state = AcceptorState::CapabilitiesSendServer {
early_capability,
channels: channels.clone(),
};
let saved_for_reactivation = AcceptorState::CapabilitiesSendServer {
early_capability,
channels,
};
Ok(Self {
security: consumed.security,
state,
user_channel_id: consumed.user_channel_id,
io_channel_id: consumed.io_channel_id,
desktop_size,
server_capabilities: consumed.server_capabilities,
static_channels,
saved_for_reactivation,
creds: consumed.creds,
reactivation: true,
})
}
pub fn attach_static_channel<T>(&mut self, channel: T)
where
T: SvcServerProcessor + 'static,
{
self.static_channels.insert(channel);
}
pub fn reached_security_upgrade(&self) -> Option<SecurityProtocol> {
match self.state {
AcceptorState::SecurityUpgrade { .. } => Some(self.security),
_ => None,
}
}
/// # Panics
///
/// Panics if state is not [AcceptorState::SecurityUpgrade].
pub fn mark_security_upgrade_as_done(&mut self) {
assert!(self.reached_security_upgrade().is_some());
self.step(&[], &mut WriteBuf::new()).expect("transition to next state");
debug_assert!(self.reached_security_upgrade().is_none());
}
pub fn should_perform_credssp(&self) -> bool {
matches!(self.state, AcceptorState::Credssp { .. })
}
/// # Panics
///
/// Panics if state is not [AcceptorState::Credssp].
pub fn mark_credssp_as_done(&mut self) {
assert!(self.should_perform_credssp());
let res = self.step(&[], &mut WriteBuf::new()).expect("transition to next state");
debug_assert!(!self.should_perform_credssp());
assert_eq!(res, Written::Nothing);
}
pub fn get_result(&mut self) -> Option<AcceptorResult> {
match mem::take(&mut self.state) {
AcceptorState::Accepted {
channels: _channels, // TODO: what about ChannelDef?
client_capabilities,
input_events,
} => Some(AcceptorResult {
static_channels: mem::take(&mut self.static_channels),
capabilities: client_capabilities,
input_events,
user_channel_id: self.user_channel_id,
io_channel_id: self.io_channel_id,
reactivation: self.reactivation,
}),
previous_state => {
self.state = previous_state;
None
}
}
}
}
#[derive(Default, Debug)]
pub enum AcceptorState {
#[default]
Consumed,
InitiationWaitRequest,
InitiationSendConfirm {
requested_protocol: SecurityProtocol,
},
SecurityUpgrade {
requested_protocol: SecurityProtocol,
protocol: SecurityProtocol,
},
Credssp {
requested_protocol: SecurityProtocol,
protocol: SecurityProtocol,
},
BasicSettingsWaitInitial {
requested_protocol: SecurityProtocol,
protocol: SecurityProtocol,
},
BasicSettingsSendResponse {
requested_protocol: SecurityProtocol,
protocol: SecurityProtocol,
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, Option<gcc::ChannelDef>)>,
},
ChannelConnection {
protocol: SecurityProtocol,
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, gcc::ChannelDef)>,
connection: ChannelConnectionSequence,
},
RdpSecurityCommencement {
protocol: SecurityProtocol,
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, gcc::ChannelDef)>,
},
SecureSettingsExchange {
protocol: SecurityProtocol,
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, gcc::ChannelDef)>,
},
LicensingExchange {
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, gcc::ChannelDef)>,
},
CapabilitiesSendServer {
early_capability: Option<gcc::ClientEarlyCapabilityFlags>,
channels: Vec<(u16, gcc::ChannelDef)>,
},
MonitorLayoutSend {
channels: Vec<(u16, gcc::ChannelDef)>,
},
CapabilitiesWaitConfirm {
channels: Vec<(u16, gcc::ChannelDef)>,
},
ConnectionFinalization {
finalization: FinalizationSequence,
channels: Vec<(u16, gcc::ChannelDef)>,
client_capabilities: Vec<CapabilitySet>,
},
Accepted {
channels: Vec<(u16, gcc::ChannelDef)>,
client_capabilities: Vec<CapabilitySet>,
input_events: Vec<Vec<u8>>,
},
}
impl State for AcceptorState {
fn name(&self) -> &'static str {
match self {
Self::Consumed => "Consumed",
Self::InitiationWaitRequest => "InitiationWaitRequest",
Self::InitiationSendConfirm { .. } => "InitiationSendConfirm",
Self::SecurityUpgrade { .. } => "SecurityUpgrade",
Self::Credssp { .. } => "Credssp",
Self::BasicSettingsWaitInitial { .. } => "BasicSettingsWaitInitial",
Self::BasicSettingsSendResponse { .. } => "BasicSettingsSendResponse",
Self::ChannelConnection { .. } => "ChannelConnection",
Self::RdpSecurityCommencement { .. } => "RdpSecurityCommencement",
Self::SecureSettingsExchange { .. } => "SecureSettingsExchange",
Self::LicensingExchange { .. } => "LicensingExchange",
Self::CapabilitiesSendServer { .. } => "CapabilitiesSendServer",
Self::MonitorLayoutSend { .. } => "MonitorLayoutSend",
Self::CapabilitiesWaitConfirm { .. } => "CapabilitiesWaitConfirm",
Self::ConnectionFinalization { .. } => "ConnectionFinalization",
Self::Accepted { .. } => "Connected",
}
}
fn is_terminal(&self) -> bool {
matches!(self, Self::Accepted { .. })
}
fn as_any(&self) -> &dyn core::any::Any {
self
}
}
impl Sequence for Acceptor {
fn next_pdu_hint(&self) -> Option<&dyn pdu::PduHint> {
match &self.state {
AcceptorState::Consumed => None,
AcceptorState::InitiationWaitRequest => Some(&pdu::X224_HINT),
AcceptorState::InitiationSendConfirm { .. } => None,
AcceptorState::SecurityUpgrade { .. } => None,
AcceptorState::Credssp { .. } => None,
AcceptorState::BasicSettingsWaitInitial { .. } => Some(&pdu::X224_HINT),
AcceptorState::BasicSettingsSendResponse { .. } => None,
AcceptorState::ChannelConnection { connection, .. } => connection.next_pdu_hint(),
AcceptorState::RdpSecurityCommencement { .. } => None,
AcceptorState::SecureSettingsExchange { .. } => Some(&pdu::X224_HINT),
AcceptorState::LicensingExchange { .. } => None,
AcceptorState::CapabilitiesSendServer { .. } => None,
AcceptorState::MonitorLayoutSend { .. } => None,
AcceptorState::CapabilitiesWaitConfirm { .. } => Some(&pdu::X224_HINT),
AcceptorState::ConnectionFinalization { finalization, .. } => finalization.next_pdu_hint(),
AcceptorState::Accepted { .. } => None,
}
}
fn state(&self) -> &dyn State {
&self.state
}
fn step(&mut self, input: &[u8], output: &mut WriteBuf) -> ConnectorResult<Written> {
let prev_state = mem::take(&mut self.state);
let (written, next_state) = match prev_state {
AcceptorState::InitiationWaitRequest => {
let connection_request = decode::<X224<nego::ConnectionRequest>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
debug!(message = ?connection_request, "Received");
(
Written::Nothing,
AcceptorState::InitiationSendConfirm {
requested_protocol: connection_request.protocol,
},
)
}
AcceptorState::InitiationSendConfirm { requested_protocol } => {
let protocols = requested_protocol & self.security;
let protocol = if protocols.intersects(SecurityProtocol::HYBRID_EX) {
SecurityProtocol::HYBRID_EX
} else if protocols.intersects(SecurityProtocol::HYBRID) {
SecurityProtocol::HYBRID
} else if protocols.intersects(SecurityProtocol::SSL) {
SecurityProtocol::SSL
} else if self.security.is_empty() {
SecurityProtocol::empty()
} else {
return Err(ConnectorError::general("failed to negotiate security protocol"));
};
let connection_confirm = nego::ConnectionConfirm::Response {
flags: nego::ResponseFlags::empty(),
protocol,
};
debug!(message = ?connection_confirm, "Send");
let written =
ironrdp_core::encode_buf(&X224(connection_confirm), output).map_err(ConnectorError::encode)?;
(
Written::from_size(written)?,
AcceptorState::SecurityUpgrade {
requested_protocol,
protocol,
},
)
}
AcceptorState::SecurityUpgrade {
requested_protocol,
protocol,
} => {
debug!(?requested_protocol);
let next_state = if protocol.intersects(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX) {
AcceptorState::Credssp {
requested_protocol,
protocol,
}
} else {
AcceptorState::BasicSettingsWaitInitial {
requested_protocol,
protocol,
}
};
(Written::Nothing, next_state)
}
AcceptorState::Credssp {
requested_protocol,
protocol,
} => (
Written::Nothing,
AcceptorState::BasicSettingsWaitInitial {
requested_protocol,
protocol,
},
),
AcceptorState::BasicSettingsWaitInitial {
requested_protocol,
protocol,
} => {
let x224_payload = decode::<X224<pdu::x224::X224Data<'_>>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
let settings_initial =
decode::<mcs::ConnectInitial>(x224_payload.data.as_ref()).map_err(ConnectorError::decode)?;
debug!(message = ?settings_initial, "Received");
let gcc_blocks = settings_initial.conference_create_request.into_gcc_blocks();
let early_capability = gcc_blocks.core.optional_data.early_capability_flags;
let joined: Vec<_> = gcc_blocks
.network
.map(|network| {
network
.channels
.into_iter()
.map(|c| {
self.static_channels
.get_by_channel_name(&c.name)
.map(|(type_id, _)| (type_id, c))
})
.collect()
})
.unwrap_or_default();
#[expect(clippy::arithmetic_side_effects)] // IO channel ID is not big enough for overflowing.
let channels = joined
.into_iter()
.enumerate()
.map(|(i, channel)| {
let channel_id = u16::try_from(i).expect("always in the range") + self.io_channel_id + 1;
if let Some((type_id, c)) = channel {
self.static_channels.attach_channel_id(type_id, channel_id);
(channel_id, Some(c))
} else {
(channel_id, None)
}
})
.collect();
(
Written::Nothing,
AcceptorState::BasicSettingsSendResponse {
requested_protocol,
protocol,
early_capability,
channels,
},
)
}
AcceptorState::BasicSettingsSendResponse {
requested_protocol,
protocol,
early_capability,
channels,
} => {
let channel_ids: Vec<u16> = channels.iter().map(|&(i, _)| i).collect();
let skip_channel_join = early_capability
.is_some_and(|client| client.contains(gcc::ClientEarlyCapabilityFlags::SUPPORT_SKIP_CHANNELJOIN));
let server_blocks = create_gcc_blocks(
self.io_channel_id,
channel_ids.clone(),
requested_protocol,
skip_channel_join,
);
let settings_response = mcs::ConnectResponse {
conference_create_response: gcc::ConferenceCreateResponse::new(self.user_channel_id, server_blocks)
.map_err(ConnectorError::decode)?,
called_connect_id: 1,
domain_parameters: mcs::DomainParameters::target(),
};
debug!(message = ?settings_response, "Send");
let written = encode_x224_packet(&settings_response, output)?;
let channels = channels.into_iter().filter_map(|(i, c)| c.map(|c| (i, c))).collect();
(
Written::from_size(written)?,
AcceptorState::ChannelConnection {
protocol,
early_capability,
channels,
connection: if skip_channel_join {
ChannelConnectionSequence::skip_channel_join(self.user_channel_id)
} else {
ChannelConnectionSequence::new(self.user_channel_id, self.io_channel_id, channel_ids)
},
},
)
}
AcceptorState::ChannelConnection {
protocol,
early_capability,
channels,
mut connection,
} => {
let written = connection.step(input, output)?;
let state = if connection.is_done() {
AcceptorState::RdpSecurityCommencement {
protocol,
early_capability,
channels,
}
} else {
AcceptorState::ChannelConnection {
protocol,
early_capability,
channels,
connection,
}
};
(written, state)
}
AcceptorState::RdpSecurityCommencement {
protocol,
early_capability,
channels,
..
} => (
Written::Nothing,
AcceptorState::SecureSettingsExchange {
protocol,
early_capability,
channels,
},
),
AcceptorState::SecureSettingsExchange {
protocol,
early_capability,
channels,
} => {
let data: X224<mcs::SendDataRequest<'_>> = decode(input).map_err(ConnectorError::decode)?;
let data = data.0;
let client_info: rdp::ClientInfoPdu =
decode(data.user_data.as_ref()).map_err(ConnectorError::decode)?;
debug!(message = ?client_info, "Received");
if !protocol.intersects(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX) {
let creds = client_info.client_info.credentials;
if self.creds.as_ref() != Some(&creds) {
// FIXME: How authorization should be denied with standard RDP security?
// Since standard RDP security is not a priority, we just send a ServerDeniedConnection ServerSetErrorInfo PDU.
let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode(
ProtocolIndependentCode::ServerDeniedConnection,
));
debug!(message = ?info, "Send");
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)?;
return Err(ConnectorError::general("invalid credentials"));
}
}
(
Written::Nothing,
AcceptorState::LicensingExchange {
early_capability,
channels,
},
)
}
AcceptorState::LicensingExchange {
early_capability,
channels,
} => {
let license: LicensePdu = LicensingErrorMessage::new_valid_client()
.map_err(ConnectorError::encode)?
.into();
debug!(message = ?license, "Send");
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &license, output)?;
self.saved_for_reactivation = AcceptorState::CapabilitiesSendServer {
early_capability,
channels: channels.clone(),
};
(
Written::from_size(written)?,
AcceptorState::CapabilitiesSendServer {
early_capability,
channels,
},
)
}
AcceptorState::CapabilitiesSendServer {
early_capability,
channels,
} => {
let demand_active = rdp::headers::ShareControlHeader {
share_id: 0,
pdu_source: self.io_channel_id,
share_control_pdu: ShareControlPdu::ServerDemandActive(rdp::capability_sets::ServerDemandActive {
pdu: rdp::capability_sets::DemandActive {
source_descriptor: "".into(),
capability_sets: self.server_capabilities.clone(),
},
}),
};
debug!(message = ?demand_active, "Send");
let written = util::encode_send_data_indication(
self.user_channel_id,
self.io_channel_id,
&demand_active,
output,
)?;
let layout_flag = gcc::ClientEarlyCapabilityFlags::SUPPORT_MONITOR_LAYOUT_PDU;
let next_state = if early_capability.is_some_and(|c| c.contains(layout_flag)) {
AcceptorState::MonitorLayoutSend { channels }
} else {
AcceptorState::CapabilitiesWaitConfirm { channels }
};
(Written::from_size(written)?, next_state)
}
AcceptorState::MonitorLayoutSend { channels } => {
let monitor_layout =
rdp::headers::ShareDataPdu::MonitorLayout(rdp::finalization_messages::MonitorLayoutPdu {
monitors: vec![gcc::Monitor {
left: 0,
top: 0,
right: i32::from(self.desktop_size.width),
bottom: i32::from(self.desktop_size.height),
flags: gcc::MonitorFlags::PRIMARY,
}],
});
debug!(message = ?monitor_layout, "Send");
let share_data = wrap_share_data(monitor_layout, self.io_channel_id);
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &share_data, output)?;
(
Written::from_size(written)?,
AcceptorState::CapabilitiesWaitConfirm { channels },
)
}
AcceptorState::CapabilitiesWaitConfirm { ref channels } => {
let message = decode::<X224<mcs::McsMessage<'_>>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0);
let message = match message {
Ok(msg) => msg,
Err(e) => {
if self.reactivation {
debug!("Dropping unexpected PDU during reactivation");
self.state = prev_state;
return Ok(Written::Nothing);
} else {
return Err(e);
}
}
};
match message {
mcs::McsMessage::SendDataRequest(data) => {
let capabilities_confirm = decode::<rdp::headers::ShareControlHeader>(data.user_data.as_ref())
.map_err(ConnectorError::decode);
let capabilities_confirm = match capabilities_confirm {
Ok(capabilities_confirm) => capabilities_confirm,
Err(e) => {
if self.reactivation {
debug!("Dropping unexpected PDU during reactivation");
self.state = prev_state;
return Ok(Written::Nothing);
} else {
return Err(e);
}
}
};
debug!(message = ?capabilities_confirm, "Received");
let ShareControlPdu::ClientConfirmActive(confirm) = capabilities_confirm.share_control_pdu
else {
return Err(ConnectorError::general("expected client confirm active"));
};
(
Written::Nothing,
AcceptorState::ConnectionFinalization {
channels: channels.clone(),
finalization: FinalizationSequence::new(self.user_channel_id, self.io_channel_id),
client_capabilities: confirm.pdu.capability_sets,
},
)
}
mcs::McsMessage::DisconnectProviderUltimatum(ultimatum) => {
return Err(reason_err!("received disconnect ultimatum", "{:?}", ultimatum.reason))
}
_ => {
warn!(?message, "Unexpected MCS message received");
(Written::Nothing, prev_state)
}
}
}
AcceptorState::ConnectionFinalization {
mut finalization,
channels,
client_capabilities,
} => {
let written = finalization.step(input, output)?;
let state = if finalization.is_done() {
AcceptorState::Accepted {
channels,
client_capabilities,
input_events: finalization.into_input_events(),
}
} else {
AcceptorState::ConnectionFinalization {
finalization,
channels,
client_capabilities,
}
};
(written, state)
}
_ => unreachable!(),
};
self.state = next_state;
Ok(written)
}
}
fn create_gcc_blocks(
io_channel: u16,
channel_ids: Vec<u16>,
requested: SecurityProtocol,
skip_channel_join: bool,
) -> gcc::ServerGccBlocks {
gcc::ServerGccBlocks {
core: gcc::ServerCoreData {
version: gcc::RdpVersion::V5_PLUS,
optional_data: gcc::ServerCoreOptionalData {
client_requested_protocols: Some(requested),
early_capability_flags: skip_channel_join
.then_some(gcc::ServerEarlyCapabilityFlags::SKIP_CHANNELJOIN_SUPPORTED),
},
},
security: gcc::ServerSecurityData::no_security(),
network: gcc::ServerNetworkData {
channel_ids,
io_channel,
},
message_channel: None,
multi_transport_channel: None,
}
}

View file

@ -1,184 +0,0 @@
use ironrdp_async::NetworkClient;
use ironrdp_connector::sspi::credssp::{
CredSspServer, CredentialsProxy, ServerError, ServerMode, ServerState, TsRequest,
};
use ironrdp_connector::sspi::generator::{Generator, GeneratorState};
use ironrdp_connector::sspi::negotiate::ProtocolConfig;
use ironrdp_connector::sspi::{self, AuthIdentity, KerberosServerConfig, NegotiateConfig, NetworkRequest, Username};
use ironrdp_connector::{
custom_err, general_err, ConnectorError, ConnectorErrorKind, ConnectorResult, ServerName, Written,
};
use ironrdp_core::{other_err, WriteBuf};
use ironrdp_pdu::PduHint;
use tracing::debug;
#[derive(Debug)]
pub(crate) enum CredsspState {
Ongoing,
Finished,
ServerError(sspi::Error),
}
#[derive(Clone, Copy, Debug)]
struct CredsspTsRequestHint;
const CREDSSP_TS_REQUEST_HINT: CredsspTsRequestHint = CredsspTsRequestHint;
impl PduHint for CredsspTsRequestHint {
fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult<Option<(bool, usize)>> {
match TsRequest::read_length(bytes) {
Ok(length) => Ok(Some((true, length))),
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None),
Err(e) => Err(other_err!("CredsspTsRequestHint", source: e)),
}
}
}
pub type CredsspProcessGenerator<'a> =
Generator<'a, NetworkRequest, sspi::Result<Vec<u8>>, Result<ServerState, ServerError>>;
#[derive(Debug)]
pub struct CredsspSequence<'a> {
server: CredSspServer<CredentialsProxyImpl<'a>>,
state: CredsspState,
}
#[derive(Debug)]
struct CredentialsProxyImpl<'a> {
credentials: &'a AuthIdentity,
}
impl<'a> CredentialsProxyImpl<'a> {
fn new(credentials: &'a AuthIdentity) -> Self {
Self { credentials }
}
}
impl CredentialsProxy for CredentialsProxyImpl<'_> {
type AuthenticationData = AuthIdentity;
fn auth_data_by_user(&mut self, username: &Username) -> std::io::Result<Self::AuthenticationData> {
if username.account_name() != self.credentials.username.account_name() {
return Err(std::io::Error::other("invalid username"));
}
let mut data = self.credentials.clone();
// keep the original user/domain
data.username = username.clone();
Ok(data)
}
}
pub(crate) async fn resolve_generator(
generator: &mut CredsspProcessGenerator<'_>,
network_client: &mut impl NetworkClient,
) -> Result<ServerState, ServerError> {
let mut state = generator.start();
loop {
match state {
GeneratorState::Suspended(request) => {
let response = network_client.send(&request).await.map_err(|err| ServerError {
ts_request: None,
error: sspi::Error::new(sspi::ErrorKind::InternalError, err),
})?;
state = generator.resume(Ok(response));
}
GeneratorState::Completed(client_state) => break client_state,
}
}
}
impl<'a> CredsspSequence<'a> {
pub fn next_pdu_hint(&self) -> ConnectorResult<Option<&dyn PduHint>> {
match &self.state {
CredsspState::Ongoing => Ok(Some(&CREDSSP_TS_REQUEST_HINT)),
CredsspState::Finished => Ok(None),
CredsspState::ServerError(err) => Err(custom_err!("Credssp server error", err.clone())),
}
}
pub fn init(
creds: &'a AuthIdentity,
client_computer_name: ServerName,
public_key: Vec<u8>,
krb_config: Option<KerberosServerConfig>,
) -> ConnectorResult<Self> {
let client_computer_name = client_computer_name.into_inner();
let credentials = CredentialsProxyImpl::new(creds);
let credssp_config: Box<dyn ProtocolConfig> = if let Some(krb_config) = krb_config {
Box::new(krb_config)
} else {
Box::<sspi::ntlm::NtlmConfig>::default()
};
let server = CredSspServer::new(
public_key,
credentials,
ServerMode::Negotiate(NegotiateConfig {
protocol_config: credssp_config,
package_list: None,
client_computer_name,
}),
)
.map_err(|e| ConnectorError::new("CredSSP", ConnectorErrorKind::Credssp(e)))?;
let sequence = Self {
server,
state: CredsspState::Ongoing,
};
Ok(sequence)
}
/// Returns Some(ts_request) when a TS request is received from client,
pub fn decode_client_message(&mut self, input: &[u8]) -> ConnectorResult<Option<TsRequest>> {
match self.state {
CredsspState::Ongoing => {
let message = TsRequest::from_buffer(input).map_err(|e| custom_err!("TsRequest", e))?;
debug!(?message, "Received");
Ok(Some(message))
}
_ => Err(general_err!(
"attempted to feed client request to CredSSP sequence in an unexpected state"
)),
}
}
pub fn process_ts_request(&mut self, request: TsRequest) -> CredsspProcessGenerator<'_> {
self.server.process(request)
}
pub fn handle_process_result(
&mut self,
result: Result<ServerState, ServerError>,
output: &mut WriteBuf,
) -> ConnectorResult<Written> {
let (ts_request, next_state) = match result {
Ok(ServerState::ReplyNeeded(ts_request)) => (Some(ts_request), CredsspState::Ongoing),
Ok(ServerState::Finished(_id)) => (None, CredsspState::Finished),
Err(err) => (
err.ts_request.map(|ts_request| *ts_request),
CredsspState::ServerError(err.error),
),
};
self.state = next_state;
if let Some(ts_request) = ts_request {
debug!(?ts_request, "Send");
let length = usize::from(ts_request.buffer_len());
let unfilled_buffer = output.unfilled_to(length);
ts_request
.encode_ts_request(unfilled_buffer)
.map_err(|e| custom_err!("TsRequest", e))?;
output.advance(length);
Ok(Written::from_size(length)?)
} else {
Ok(Written::Nothing)
}
}
}

View file

@ -1,249 +0,0 @@
use ironrdp_connector::{ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written};
use ironrdp_core::WriteBuf;
use ironrdp_pdu::rdp;
use ironrdp_pdu::x224::X224;
use tracing::debug;
use crate::util::{self, wrap_share_data};
#[derive(Debug)]
pub struct FinalizationSequence {
state: FinalizationState,
user_channel_id: u16,
io_channel_id: u16,
input_events: Vec<Vec<u8>>,
}
#[derive(Default, Debug)]
pub enum FinalizationState {
#[default]
Consumed,
WaitSynchronize,
WaitControlCooperate,
WaitRequestControl,
WaitFontList,
SendSynchronizeConfirm,
SendControlCooperateConfirm,
SendGrantedControlConfirm,
SendFontMap,
Finished,
}
impl State for FinalizationState {
fn name(&self) -> &'static str {
match self {
Self::Consumed => "Consumed",
Self::WaitSynchronize => "WaitSynchronize",
Self::WaitControlCooperate => "WaitControlCooperate",
Self::WaitRequestControl => "WaitRequestControl",
Self::WaitFontList => "WaitFontList",
Self::SendSynchronizeConfirm => "SendSynchronizeConfirm",
Self::SendControlCooperateConfirm => "SendControlCooperateConfirm",
Self::SendGrantedControlConfirm => "SendGrantedControlConfirm",
Self::SendFontMap => "SendFontMap",
Self::Finished => "Finished",
}
}
fn is_terminal(&self) -> bool {
matches!(self, Self::Finished { .. })
}
fn as_any(&self) -> &dyn core::any::Any {
self
}
}
impl Sequence for FinalizationSequence {
fn next_pdu_hint(&self) -> Option<&dyn ironrdp_pdu::PduHint> {
match &self.state {
FinalizationState::Consumed => None,
FinalizationState::WaitSynchronize => Some(&ironrdp_pdu::X224Hint),
FinalizationState::WaitControlCooperate => Some(&ironrdp_pdu::X224Hint),
FinalizationState::WaitRequestControl => Some(&ironrdp_pdu::X224Hint),
FinalizationState::WaitFontList => Some(&ironrdp_pdu::RdpHint),
FinalizationState::SendSynchronizeConfirm => None,
FinalizationState::SendControlCooperateConfirm => None,
FinalizationState::SendGrantedControlConfirm => None,
FinalizationState::SendFontMap => None,
FinalizationState::Finished => None,
}
}
fn state(&self) -> &dyn State {
&self.state
}
fn step(&mut self, input: &[u8], output: &mut WriteBuf) -> ConnectorResult<Written> {
let (written, next_state) = match core::mem::take(&mut self.state) {
FinalizationState::WaitSynchronize => {
let synchronize = decode_share_control(input);
debug!(message = ?synchronize, "Received");
(Written::Nothing, FinalizationState::WaitControlCooperate)
}
FinalizationState::WaitControlCooperate => {
let cooperate = decode_share_control(input);
debug!(message = ?cooperate, "Received");
(Written::Nothing, FinalizationState::WaitRequestControl)
}
FinalizationState::WaitRequestControl => {
let control = decode_share_control(input)?;
debug!(message = ?control, "Received");
(Written::Nothing, FinalizationState::WaitFontList)
}
FinalizationState::WaitFontList => match decode_font_list(input) {
Ok(font_list) => {
debug!(message = ?font_list, "Received");
(Written::Nothing, FinalizationState::SendSynchronizeConfirm)
}
Err(()) => {
self.input_events.push(input.to_vec());
(Written::Nothing, FinalizationState::WaitFontList)
}
},
FinalizationState::SendSynchronizeConfirm => {
let synchronize_confirm = create_synchronize_confirm();
debug!(message = ?synchronize_confirm, "Send");
let share_data = wrap_share_data(synchronize_confirm, self.io_channel_id);
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &share_data, output)?;
(
Written::from_size(written)?,
FinalizationState::SendControlCooperateConfirm,
)
}
FinalizationState::SendControlCooperateConfirm => {
let cooperate_confirm = create_cooperate_confirm();
debug!(message = ?cooperate_confirm, "Send");
let share_data = wrap_share_data(cooperate_confirm, self.io_channel_id);
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &share_data, output)?;
(
Written::from_size(written)?,
FinalizationState::SendGrantedControlConfirm,
)
}
FinalizationState::SendGrantedControlConfirm => {
let control_confirm = create_control_confirm(self.user_channel_id);
debug!(message = ?control_confirm, "Send");
let share_data = wrap_share_data(control_confirm, self.io_channel_id);
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &share_data, output)?;
(Written::from_size(written)?, FinalizationState::SendFontMap)
}
FinalizationState::SendFontMap => {
let font_map = create_font_map();
debug!(message = ?font_map, "Send");
let share_data = wrap_share_data(font_map, self.io_channel_id);
let written =
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &share_data, output)?;
(Written::from_size(written)?, FinalizationState::Finished)
}
_ => unreachable!(),
};
self.state = next_state;
Ok(written)
}
}
impl FinalizationSequence {
pub fn new(user_channel_id: u16, io_channel_id: u16) -> Self {
Self {
state: FinalizationState::WaitSynchronize,
user_channel_id,
io_channel_id,
input_events: Vec::new(),
}
}
pub fn into_input_events(self) -> Vec<Vec<u8>> {
self.input_events
}
pub fn is_done(&self) -> bool {
self.state.is_terminal()
}
}
fn create_synchronize_confirm() -> rdp::headers::ShareDataPdu {
rdp::headers::ShareDataPdu::Synchronize(rdp::finalization_messages::SynchronizePdu { target_user_id: 0 })
}
fn create_cooperate_confirm() -> rdp::headers::ShareDataPdu {
rdp::headers::ShareDataPdu::Control(rdp::finalization_messages::ControlPdu {
action: rdp::finalization_messages::ControlAction::Cooperate,
grant_id: 0,
control_id: 0,
})
}
fn create_control_confirm(user_id: u16) -> rdp::headers::ShareDataPdu {
rdp::headers::ShareDataPdu::Control(rdp::finalization_messages::ControlPdu {
action: rdp::finalization_messages::ControlAction::GrantedControl,
grant_id: user_id,
control_id: u32::from(rdp::capability_sets::SERVER_CHANNEL_ID),
})
}
fn create_font_map() -> rdp::headers::ShareDataPdu {
rdp::headers::ShareDataPdu::FontMap(rdp::finalization_messages::FontPdu::default())
}
fn decode_share_control(input: &[u8]) -> ConnectorResult<rdp::headers::ShareControlHeader> {
let data_request = ironrdp_core::decode::<X224<ironrdp_pdu::mcs::SendDataRequest<'_>>>(input)
.map_err(ConnectorError::decode)
.map(|p| p.0)?;
let share_control = ironrdp_core::decode::<rdp::headers::ShareControlHeader>(data_request.user_data.as_ref())
.map_err(ConnectorError::decode)?;
Ok(share_control)
}
fn decode_font_list(input: &[u8]) -> Result<rdp::finalization_messages::FontPdu, ()> {
use ironrdp_pdu::rdp::headers::{ShareControlPdu, ShareDataPdu};
let share_control = decode_share_control(input).map_err(|_| ())?;
let ShareControlPdu::Data(data_pdu) = share_control.share_control_pdu else {
return Err(());
};
let ShareDataPdu::FontList(font_pdu) = data_pdu.share_data_pdu else {
return Err(());
};
Ok(font_pdu)
}

View file

@ -1,225 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
use ironrdp_async::{single_sequence_step, Framed, FramedRead, FramedWrite, NetworkClient, StreamWrapper};
use ironrdp_connector::sspi::credssp::EarlyUserAuthResult;
use ironrdp_connector::sspi::{AuthIdentity, KerberosServerConfig, Username};
use ironrdp_connector::{custom_err, general_err, ConnectorResult, ServerName};
use ironrdp_core::WriteBuf;
use tracing::{debug, instrument, trace};
mod channel_connection;
mod connection;
pub mod credssp;
mod finalization;
mod util;
pub use ironrdp_connector::DesktopSize;
use ironrdp_pdu::nego;
pub use self::channel_connection::{ChannelConnectionSequence, ChannelConnectionState};
pub use self::connection::{Acceptor, AcceptorResult, AcceptorState};
pub use self::finalization::{FinalizationSequence, FinalizationState};
use crate::credssp::resolve_generator;
pub enum BeginResult<S>
where
S: StreamWrapper,
{
ShouldUpgrade(S::InnerStream),
Continue(Framed<S>),
}
pub async fn accept_begin<S>(mut framed: Framed<S>, acceptor: &mut Acceptor) -> ConnectorResult<BeginResult<S>>
where
S: FramedRead + FramedWrite + StreamWrapper,
{
let mut buf = WriteBuf::new();
loop {
if let Some(security) = acceptor.reached_security_upgrade() {
let result = if security.is_empty() {
BeginResult::Continue(framed)
} else {
BeginResult::ShouldUpgrade(framed.into_inner_no_leftover())
};
return Ok(result);
}
single_sequence_step(&mut framed, acceptor, &mut buf).await?;
}
}
pub async fn accept_credssp<S, N>(
framed: &mut Framed<S>,
acceptor: &mut Acceptor,
network_client: &mut N,
client_computer_name: ServerName,
public_key: Vec<u8>,
kerberos_config: Option<KerberosServerConfig>,
) -> ConnectorResult<()>
where
S: FramedRead + FramedWrite,
N: NetworkClient,
{
let mut buf = WriteBuf::new();
if acceptor.should_perform_credssp() {
perform_credssp_step(
framed,
acceptor,
network_client,
&mut buf,
client_computer_name,
public_key,
kerberos_config,
)
.await
} else {
Ok(())
}
}
pub async fn accept_finalize<S>(
mut framed: Framed<S>,
acceptor: &mut Acceptor,
) -> ConnectorResult<(Framed<S>, AcceptorResult)>
where
S: FramedRead + FramedWrite,
{
let mut buf = WriteBuf::new();
loop {
if let Some(result) = acceptor.get_result() {
return Ok((framed, result));
}
single_sequence_step(&mut framed, acceptor, &mut buf).await?;
}
}
#[instrument(level = "trace", skip_all, ret)]
async fn perform_credssp_step<S, N>(
framed: &mut Framed<S>,
acceptor: &mut Acceptor,
network_client: &mut N,
buf: &mut WriteBuf,
client_computer_name: ServerName,
public_key: Vec<u8>,
kerberos_config: Option<KerberosServerConfig>,
) -> ConnectorResult<()>
where
S: FramedRead + FramedWrite,
N: NetworkClient,
{
assert!(acceptor.should_perform_credssp());
let AcceptorState::Credssp { protocol, .. } = acceptor.state else {
unreachable!()
};
let result = credssp_loop(
framed,
acceptor,
network_client,
buf,
client_computer_name,
public_key,
kerberos_config,
)
.await;
if protocol.intersects(nego::SecurityProtocol::HYBRID_EX) {
trace!(?result, "HYBRID_EX");
let result = if result.is_ok() {
EarlyUserAuthResult::Success
} else {
EarlyUserAuthResult::AccessDenied
};
buf.clear();
result
.to_buffer(&mut *buf)
.map_err(|e| ironrdp_connector::custom_err!("to_buffer", e))?;
let response = &buf[..result.buffer_len()];
framed
.write_all(response)
.await
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
result?;
acceptor.mark_credssp_as_done();
return Ok(());
async fn credssp_loop<S, N>(
framed: &mut Framed<S>,
acceptor: &mut Acceptor,
network_client: &mut N,
buf: &mut WriteBuf,
client_computer_name: ServerName,
public_key: Vec<u8>,
kerberos_config: Option<KerberosServerConfig>,
) -> ConnectorResult<()>
where
S: FramedRead + FramedWrite,
N: NetworkClient,
{
let creds = acceptor
.creds
.as_ref()
.ok_or_else(|| general_err!("no credentials while doing credssp"))?;
let username = Username::new(&creds.username, None).map_err(|e| custom_err!("invalid username", e))?;
let identity = AuthIdentity {
username,
password: creds.password.clone().into(),
};
let mut sequence =
credssp::CredsspSequence::init(&identity, client_computer_name, public_key, kerberos_config)?;
loop {
let Some(next_pdu_hint) = sequence.next_pdu_hint()? else {
break;
};
debug!(
acceptor.state = ?acceptor.state,
hint = ?next_pdu_hint,
"Wait for PDU"
);
let pdu = framed
.read_by_hint(next_pdu_hint)
.await
.map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?;
trace!(length = pdu.len(), "PDU received");
let Some(ts_request) = sequence.decode_client_message(&pdu)? else {
break;
};
let result = {
let mut generator = sequence.process_ts_request(ts_request);
resolve_generator(&mut generator, network_client).await
}; // drop generator
buf.clear();
let written = sequence.handle_process_result(result, buf)?;
if let Some(response_len) = written.size() {
let response = &buf[..response_len];
trace!(response_len, "Send response");
framed
.write_all(response)
.await
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
}
Ok(())
}
}

View file

@ -1,41 +0,0 @@
use std::borrow::Cow;
use ironrdp_connector::{ConnectorError, ConnectorErrorExt as _, ConnectorResult};
use ironrdp_core::{encode_vec, Encode, WriteBuf};
use ironrdp_pdu::rdp;
use ironrdp_pdu::x224::X224;
pub(crate) fn encode_send_data_indication<T>(
initiator_id: u16,
channel_id: u16,
user_msg: &T,
buf: &mut WriteBuf,
) -> ConnectorResult<usize>
where
T: Encode,
{
let user_data = encode_vec(user_msg).map_err(ConnectorError::encode)?;
let pdu = ironrdp_pdu::mcs::SendDataIndication {
initiator_id,
channel_id,
user_data: Cow::Owned(user_data),
};
let written = ironrdp_core::encode_buf(&X224(pdu), buf).map_err(ConnectorError::encode)?;
Ok(written)
}
pub(crate) fn wrap_share_data(pdu: rdp::headers::ShareDataPdu, io_channel_id: u16) -> rdp::headers::ShareControlHeader {
rdp::headers::ShareControlHeader {
share_id: 0,
pdu_source: io_channel_id,
share_control_pdu: rdp::headers::ShareControlPdu::Data(rdp::headers::ShareDataHeader {
share_data_pdu: pdu,
stream_priority: rdp::headers::StreamPriority::Undefined,
compression_flags: rdp::headers::CompressionFlags::empty(),
compression_type: rdp::client_info::CompressionType::K8,
}),
}
}

View file

@ -1,36 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.2.0...ironrdp-ainput-v0.2.1)] - 2025-05-27
### <!-- 7 -->Build
- Bump bitflags from 2.9.0 to 2.9.1 in the patch group across 1 directory (#792) ([87ed315bc2](https://github.com/Devolutions/IronRDP/commit/87ed315bc28fdd2dcfea89b052fa620a7e346e5a))
## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.2...ironrdp-ainput-v0.1.3)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.1...ironrdp-ainput-v0.1.2)] - 2025-01-28
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.0...ironrdp-ainput-v0.1.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))

View file

@ -1,27 +0,0 @@
[package]
name = "ironrdp-ainput"
version = "0.4.0"
readme = "README.md"
description = "AInput dynamic channel implementation"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public
ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } # public
bitflags = "2.9"
num-derive.workspace = true # TODO: remove
num-traits.workspace = true # TODO: remove
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,8 +0,0 @@
# IronRDP AInput
Implements the "Advanced Input" dynamic channel as defined from [Freerdp][here].
This crate is part of the [IronRDP] project.
[here]: https://github.com/FreeRDP/FreeRDP/blob/master/include/freerdp/channels/ainput.h
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,290 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
use bitflags::bitflags;
use ironrdp_core::{
ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor,
};
use ironrdp_dvc::DvcEncode;
use num_derive::FromPrimitive;
use num_traits::FromPrimitive as _;
// Advanced Input channel as defined from Freerdp, [here]:
//
// [here]: https://github.com/FreeRDP/FreeRDP/blob/master/include/freerdp/channels/ainput.h
const VERSION_MAJOR: u32 = 1;
const VERSION_MINOR: u32 = 0;
pub const CHANNEL_NAME: &str = "FreeRDP::Advanced::Input";
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MouseEventFlags: u64 {
const WHEEL = 0x0000_0001;
const MOVE = 0x0000_0004;
const DOWN = 0x0000_0008;
const REL = 0x0000_0010;
const HAVE_REL = 0x0000_0020;
const BUTTON1 = 0x0000_1000; /* left */
const BUTTON2 = 0x0000_2000; /* right */
const BUTTON3 = 0x0000_4000; /* middle */
const XBUTTON1 = 0x0000_0100;
const XBUTTON2 = 0x0000_0200;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionPdu {
major_version: u32,
minor_version: u32,
}
impl VersionPdu {
const NAME: &'static str = "AInputVersionPdu";
const FIXED_PART_SIZE: usize = 4 /* MajorVersion */ + 4 /* MinorVersion */;
pub fn new() -> Self {
Self {
major_version: VERSION_MAJOR,
minor_version: VERSION_MINOR,
}
}
}
impl Default for VersionPdu {
fn default() -> Self {
Self::new()
}
}
impl Encode for VersionPdu {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u32(self.major_version);
dst.write_u32(self.minor_version);
Ok(())
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
}
}
impl<'de> Decode<'de> for VersionPdu {
fn decode(src: &mut ReadCursor<'de>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let major_version = src.read_u32();
let minor_version = src.read_u32();
Ok(Self {
major_version,
minor_version,
})
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)]
#[repr(u16)]
pub enum ServerPduType {
Version = 0x01,
}
impl ServerPduType {
#[expect(
clippy::as_conversions,
reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive"
)]
fn as_u16(&self) -> u16 {
*self as u16
}
}
impl<'a> From<&'a ServerPdu> for ServerPduType {
fn from(s: &'a ServerPdu) -> Self {
match s {
ServerPdu::Version(_) => Self::Version,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerPdu {
Version(VersionPdu),
}
impl ServerPdu {
const NAME: &'static str = "AInputServerPdu";
const FIXED_PART_SIZE: usize = 2 /* PduType */;
}
impl Encode for ServerPdu {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u16(ServerPduType::from(self).as_u16());
match self {
ServerPdu::Version(pdu) => pdu.encode(dst),
}
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
.checked_add(match self {
ServerPdu::Version(pdu) => pdu.size(),
})
.expect("never overflow")
}
}
impl DvcEncode for ServerPdu {}
impl<'de> Decode<'de> for ServerPdu {
fn decode(src: &mut ReadCursor<'de>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let pdu_type =
ServerPduType::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("pduType", "invalid pdu type"))?;
let server_pdu = match pdu_type {
ServerPduType::Version => ServerPdu::Version(VersionPdu::decode(src)?),
};
Ok(server_pdu)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MousePdu {
pub time: u64,
pub flags: MouseEventFlags,
pub x: i32,
pub y: i32,
}
impl MousePdu {
const NAME: &'static str = "AInputMousePdu";
const FIXED_PART_SIZE: usize = 8 /* Time */ + 8 /* Flags */ + 4 /* X */ + 4 /* Y */;
}
impl Encode for MousePdu {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u64(self.time);
dst.write_u64(self.flags.bits());
dst.write_i32(self.x);
dst.write_i32(self.y);
Ok(())
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
}
}
impl<'de> Decode<'de> for MousePdu {
fn decode(src: &mut ReadCursor<'de>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let time = src.read_u64();
let flags = MouseEventFlags::from_bits_retain(src.read_u64());
let x = src.read_i32();
let y = src.read_i32();
Ok(Self { time, flags, x, y })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientPdu {
Mouse(MousePdu),
}
impl ClientPdu {
const NAME: &'static str = "AInputClientPdu";
const FIXED_PART_SIZE: usize = 2 /* PduType */;
}
impl Encode for ClientPdu {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u16(ClientPduType::from(self).as_u16());
match self {
ClientPdu::Mouse(pdu) => pdu.encode(dst),
}
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
.checked_add(match self {
ClientPdu::Mouse(pdu) => pdu.size(),
})
.expect("never overflow")
}
}
impl<'de> Decode<'de> for ClientPdu {
fn decode(src: &mut ReadCursor<'de>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let pdu_type =
ClientPduType::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("pduType", "invalid pdu type"))?;
let client_pdu = match pdu_type {
ClientPduType::Mouse => ClientPdu::Mouse(MousePdu::decode(src)?),
};
Ok(client_pdu)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)]
#[repr(u16)]
pub enum ClientPduType {
Mouse = 0x02,
}
impl ClientPduType {
#[expect(
clippy::as_conversions,
reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive"
)]
fn as_u16(self) -> u16 {
self as u16
}
}
impl<'a> From<&'a ClientPdu> for ClientPduType {
fn from(s: &'a ClientPdu) -> Self {
match s {
ClientPdu::Mouse(_) => Self::Mouse,
}
}
}

View file

@ -1,48 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.7.0...ironrdp-async-v0.8.0)] - 2025-12-18
### <!-- 4 -->Bug Fixes
- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a))
- Rename `AsyncNetworkClient` to `NetworkClient`
- Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch
using generics (`&mut N where N: NetworkClient`)
- Reorder `connect_finalize` parameters for consistency across crates
## [[0.3.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.3.1...ironrdp-async-v0.3.2)] - 2025-03-12
### <!-- 7 -->Build
- Bump ironrdp-pdu
## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.3.0...ironrdp-async-v0.3.1)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.2.1...ironrdp-async-v0.3.0)] - 2025-01-28
### <!-- 4 -->Changed
- Remove unmatched parameter from `Framed::read_by_hint` function ([63963182b5](https://github.com/Devolutions/IronRDP/commit/63963182b5af6ad45dc638e93de4b8a0b565c7d3))
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.2.0...ironrdp-async-v0.2.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))

View file

@ -1,26 +0,0 @@
[package]
name = "ironrdp-async"
version = "0.8.0"
readme = "README.md"
description = "Provides `Future`s wrapping the IronRDP state machines conveniently"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public
tracing = { version = "0.1", features = ["log"] }
bytes = "1" # public
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,7 +0,0 @@
# IronRDP Async
`Future`s built on top of `ironrdp-connector` and `ironrdp-session` crates.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,189 +0,0 @@
use ironrdp_connector::credssp::{CredsspProcessGenerator, CredsspSequence, KerberosConfig};
use ironrdp_connector::sspi::credssp::ClientState;
use ironrdp_connector::sspi::generator::GeneratorState;
use ironrdp_connector::{
general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, ServerName,
State as _,
};
use ironrdp_core::WriteBuf;
use tracing::{debug, info, instrument, trace};
use crate::framed::{Framed, FramedRead, FramedWrite};
use crate::{single_sequence_step, NetworkClient};
#[non_exhaustive]
pub struct ShouldUpgrade;
#[instrument(skip_all)]
pub async fn connect_begin<S>(framed: &mut Framed<S>, connector: &mut ClientConnector) -> ConnectorResult<ShouldUpgrade>
where
S: Sync + FramedRead + FramedWrite,
{
let mut buf = WriteBuf::new();
info!("Begin connection procedure");
while !connector.should_perform_security_upgrade() {
single_sequence_step(framed, connector, &mut buf).await?;
}
Ok(ShouldUpgrade)
}
/// # Panics
///
/// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade].
pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade {
assert!(connector.should_perform_security_upgrade());
ShouldUpgrade
}
#[non_exhaustive]
pub struct Upgraded;
#[instrument(skip_all)]
pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Upgraded {
trace!("Marked as upgraded");
connector.mark_security_upgrade_as_done();
Upgraded
}
#[instrument(skip_all)]
pub async fn connect_finalize<S, N>(
_: Upgraded,
mut connector: ClientConnector,
framed: &mut Framed<S>,
network_client: &mut N,
server_name: ServerName,
server_public_key: Vec<u8>,
kerberos_config: Option<KerberosConfig>,
) -> ConnectorResult<ConnectionResult>
where
S: FramedRead + FramedWrite,
N: NetworkClient,
{
let mut buf = WriteBuf::new();
if connector.should_perform_credssp() {
perform_credssp_step(
&mut connector,
framed,
network_client,
&mut buf,
server_name,
server_public_key,
kerberos_config,
)
.await?;
}
let result = loop {
single_sequence_step(framed, &mut connector, &mut buf).await?;
if let ClientConnectorState::Connected { result } = connector.state {
break result;
}
};
info!("Connected with success");
Ok(result)
}
async fn resolve_generator(
generator: &mut CredsspProcessGenerator<'_>,
network_client: &mut impl NetworkClient,
) -> ConnectorResult<ClientState> {
let mut state = generator.start();
loop {
match state {
GeneratorState::Suspended(request) => {
let response = network_client.send(&request).await?;
state = generator.resume(Ok(response));
}
GeneratorState::Completed(client_state) => {
break client_state
.map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)))
}
}
}
}
#[instrument(level = "trace", skip_all)]
async fn perform_credssp_step<S, N>(
connector: &mut ClientConnector,
framed: &mut Framed<S>,
network_client: &mut N,
buf: &mut WriteBuf,
server_name: ServerName,
server_public_key: Vec<u8>,
kerberos_config: Option<KerberosConfig>,
) -> ConnectorResult<()>
where
S: FramedRead + FramedWrite,
N: NetworkClient,
{
assert!(connector.should_perform_credssp());
let selected_protocol = match connector.state {
ClientConnectorState::Credssp { selected_protocol, .. } => selected_protocol,
_ => return Err(general_err!("invalid connector state for CredSSP sequence")),
};
let (mut sequence, mut ts_request) = CredsspSequence::init(
connector.config.credentials.clone(),
connector.config.domain.as_deref(),
selected_protocol,
server_name,
server_public_key,
kerberos_config,
)?;
loop {
let client_state = {
let mut generator = sequence.process_ts_request(ts_request);
trace!("resolving network");
resolve_generator(&mut generator, network_client).await?
}; // drop generator
buf.clear();
let written = sequence.handle_process_result(client_state, buf)?;
if let Some(response_len) = written.size() {
let response = &buf[..response_len];
trace!(response_len, "Send response");
framed
.write_all(response)
.await
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
let Some(next_pdu_hint) = sequence.next_pdu_hint() else {
break;
};
debug!(
connector.state = connector.state.name(),
hint = ?next_pdu_hint,
"Wait for PDU"
);
let pdu = framed
.read_by_hint(next_pdu_hint)
.await
.map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?;
trace!(length = pdu.len(), "PDU received");
if let Some(next_request) = sequence.decode_server_message(&pdu)? {
ts_request = next_request;
} else {
break;
}
}
connector.mark_credssp_as_done();
Ok(())
}

View file

@ -1,290 +0,0 @@
use std::io;
use bytes::{Bytes, BytesMut};
use ironrdp_connector::{ConnectorResult, Sequence, Written};
use ironrdp_core::WriteBuf;
use ironrdp_pdu::PduHint;
use tracing::{debug, trace};
// TODO: investigate if we could use static async fn / return position impl trait in traits when stabilized:
// https://github.com/rust-lang/rust/issues/91611
pub trait FramedRead {
type ReadFut<'read>: core::future::Future<Output = io::Result<usize>> + 'read
where
Self: 'read;
/// Reads from stream and fills internal buffer
///
/// # Cancel safety
///
/// This method is cancel safe. If you use it as the event in a
/// `tokio::select!` statement and some other branch
/// completes first, then it is guaranteed that no data was read.
fn read<'a>(&'a mut self, buf: &'a mut BytesMut) -> Self::ReadFut<'a>;
}
pub trait FramedWrite {
type WriteAllFut<'write>: core::future::Future<Output = io::Result<()>> + 'write
where
Self: 'write;
/// Writes an entire buffer into this stream.
///
/// # Cancel safety
///
/// This method is not cancellation safe. If it is used as the event
/// in a `tokio::select!` statement and some other
/// branch completes first, then the provided buffer may have been
/// partially written, but future calls to `write_all` will start over
/// from the beginning of the buffer.
fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> Self::WriteAllFut<'a>;
}
pub trait StreamWrapper: Sized {
type InnerStream;
fn from_inner(stream: Self::InnerStream) -> Self;
fn into_inner(self) -> Self::InnerStream;
fn get_inner(&self) -> &Self::InnerStream;
fn get_inner_mut(&mut self) -> &mut Self::InnerStream;
}
pub struct Framed<S> {
stream: S,
buf: BytesMut,
}
impl<S> Framed<S> {
pub fn peek(&self) -> &[u8] {
&self.buf
}
}
impl<S> Framed<S>
where
S: StreamWrapper,
{
pub fn new(stream: S::InnerStream) -> Self {
Self::new_with_leftover(stream, BytesMut::new())
}
pub fn new_with_leftover(stream: S::InnerStream, leftover: BytesMut) -> Self {
Self {
stream: S::from_inner(stream),
buf: leftover,
}
}
pub fn into_inner(self) -> (S::InnerStream, BytesMut) {
(self.stream.into_inner(), self.buf)
}
pub fn into_inner_no_leftover(self) -> S::InnerStream {
let (stream, leftover) = self.into_inner();
debug_assert_eq!(leftover.len(), 0, "unexpected leftover");
stream
}
pub fn get_inner(&self) -> (&S::InnerStream, &BytesMut) {
(self.stream.get_inner(), &self.buf)
}
pub fn get_inner_mut(&mut self) -> (&mut S::InnerStream, &mut BytesMut) {
(self.stream.get_inner_mut(), &mut self.buf)
}
}
impl<S> Framed<S>
where
S: FramedRead,
{
/// Accumulates at least `length` bytes and returns exactly `length` bytes, keeping the leftover in the internal buffer.
///
/// # Cancel safety
///
/// This method is cancel safe. If you use it as the event in a
/// `tokio::select!` statement and some other branch
/// completes first, then it is safe to drop the future and re-create it later.
/// Data may have been read, but it will be stored in the internal buffer.
pub async fn read_exact(&mut self, length: usize) -> io::Result<BytesMut> {
loop {
if self.buf.len() >= length {
return Ok(self.buf.split_to(length));
} else {
#[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer underflow)")]
self.buf
.reserve(length.checked_sub(self.buf.len()).expect("length > self.buf.len()"));
}
let len = self.read().await?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
}
/// Reads a standard RDP PDU frame.
///
/// # Cancel safety
///
/// This method is cancel safe. If you use it as the event in a
/// `tokio::select!` statement and some other branch
/// completes first, then it is safe to drop the future and re-create it later.
/// Data may have been read, but it will be stored in the internal buffer.
pub async fn read_pdu(&mut self) -> io::Result<(ironrdp_pdu::Action, BytesMut)> {
loop {
// Try decoding and see if a frame has been received already
match ironrdp_pdu::find_size(self.peek()) {
Ok(Some(pdu_info)) => {
let frame = self.read_exact(pdu_info.length).await?;
return Ok((pdu_info.action, frame));
}
Ok(None) => {
let len = self.read().await?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
Err(e) => return Err(io::Error::other(e)),
};
}
}
/// Reads a frame using the provided PduHint.
///
/// # Cancel safety
///
/// This method is cancel safe. If you use it as the event in a
/// `tokio::select!` statement and some other branch
/// completes first, then it is safe to drop the future and re-create it later.
/// Data may have been read, but it will be stored in the internal buffer.
pub async fn read_by_hint(&mut self, hint: &dyn PduHint) -> io::Result<Bytes> {
loop {
match hint.find_size(self.peek()).map_err(io::Error::other)? {
Some((matched, length)) => {
let bytes = self.read_exact(length).await?.freeze();
if matched {
return Ok(bytes);
} else {
debug!("Received and lost an unexpected PDU");
}
}
None => {
let len = self.read().await?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
};
}
}
/// Reads from stream and fills internal buffer, returning how many bytes were read.
///
/// # Cancel safety
///
/// This method is cancel safe. If you use it as the event in a
/// `tokio::select!` statement and some other branch
/// completes first, then it is guaranteed that no data was read.
async fn read(&mut self) -> io::Result<usize> {
self.stream.read(&mut self.buf).await
}
}
impl<S> FramedWrite for Framed<S>
where
S: FramedWrite,
{
type WriteAllFut<'write>
= S::WriteAllFut<'write>
where
Self: 'write;
/// Attempts to write an entire buffer into this `Framed`s stream.
///
/// # Cancel safety
///
/// This method is not cancellation safe. If it is used as the event
/// in a `tokio::select!` statement and some other
/// branch completes first, then the provided buffer may have been
/// partially written, but future calls to `write_all` will start over
/// from the beginning of the buffer.
fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> Self::WriteAllFut<'a> {
self.stream.write_all(buf)
}
}
pub async fn single_sequence_step<S>(
framed: &mut Framed<S>,
sequence: &mut dyn Sequence,
buf: &mut WriteBuf,
) -> ConnectorResult<()>
where
S: FramedWrite + FramedRead,
{
buf.clear();
let written = single_sequence_step_read(framed, sequence, buf).await?;
single_sequence_step_write(framed, buf, written).await
}
pub async fn single_sequence_step_read<S>(
framed: &mut Framed<S>,
sequence: &mut dyn Sequence,
buf: &mut WriteBuf,
) -> ConnectorResult<Written>
where
S: FramedRead,
{
buf.clear();
if let Some(next_pdu_hint) = sequence.next_pdu_hint() {
debug!(
connector.state = sequence.state().name(),
hint = ?next_pdu_hint,
"Wait for PDU"
);
let pdu = framed
.read_by_hint(next_pdu_hint)
.await
.map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?;
trace!(length = pdu.len(), "PDU received");
sequence.step(&pdu, buf)
} else {
sequence.step_no_input(buf)
}
}
async fn single_sequence_step_write<S>(
framed: &mut Framed<S>,
buf: &mut WriteBuf,
written: Written,
) -> ConnectorResult<()>
where
S: FramedWrite,
{
if let Some(response_len) = written.size() {
debug_assert_eq!(buf.filled_len(), response_len);
let response = buf.filled();
trace!(response_len, "Send response");
framed
.write_all(response)
.await
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
Ok(())
}

View file

@ -1,21 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
use core::future::Future;
pub use bytes;
mod connector;
mod framed;
mod session;
use ironrdp_connector::sspi::generator::NetworkRequest;
use ironrdp_connector::ConnectorResult;
pub use self::connector::*;
pub use self::framed::*;
// pub use self::session::*;
pub trait NetworkClient {
fn send(&mut self, network_request: &NetworkRequest) -> impl Future<Output = ConnectorResult<Vec<u8>>>;
}

View file

@ -1 +0,0 @@
// TODO: active session async helpers

View file

@ -1,20 +0,0 @@
[package]
name = "ironrdp-bench"
version = "0.0.0"
description = "IronRDP benchmarks"
edition.workspace = true
publish = false
[dev-dependencies]
criterion = "0.8"
ironrdp-graphics.path = "../ironrdp-graphics"
ironrdp-pdu.path = "../ironrdp-pdu"
ironrdp-server = { path = "../ironrdp-server", features = ["__bench"] }
[[bench]]
name = "bench"
path = "benches/bench.rs"
harness = false
[lints]
workspace = true

View file

@ -1,80 +0,0 @@
#![expect(clippy::missing_panics_doc, reason = "panics in benches are allowed")]
use core::num::{NonZeroU16, NonZeroUsize};
use criterion::{criterion_group, criterion_main, Criterion};
use ironrdp_graphics::color_conversion::to_64x64_ycbcr_tile;
use ironrdp_pdu::codecs::rfx;
use ironrdp_server::bench::encoder::rfx::{rfx_enc, rfx_enc_tile};
use ironrdp_server::BitmapUpdate;
pub fn rfx_enc_tile_bench(c: &mut Criterion) {
const WIDTH: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero");
const HEIGHT: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero");
const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero");
let quant = rfx::Quant::default();
let algo = rfx::EntropyAlgorithm::Rlgr3;
let bitmap = BitmapUpdate {
x: 0,
y: 0,
width: WIDTH,
height: HEIGHT,
format: ironrdp_server::PixelFormat::ARgb32,
data: vec![0; 64 * 64 * 4].into(),
stride: STRIDE,
};
c.bench_function("rfx_enc_tile", |b| b.iter(|| rfx_enc_tile(&bitmap, &quant, algo, 0, 0)));
}
pub fn rfx_enc_bench(c: &mut Criterion) {
const WIDTH: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero");
const HEIGHT: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero");
// FIXME/QUESTION: It looks like we have a bug here, don't we? The stride value should be 2048 * 4.
const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero");
let quant = rfx::Quant::default();
let algo = rfx::EntropyAlgorithm::Rlgr3;
let bitmap = BitmapUpdate {
x: 0,
y: 0,
width: WIDTH,
height: HEIGHT,
format: ironrdp_server::PixelFormat::ARgb32,
data: vec![0; 2048 * 2048 * 4].into(),
stride: STRIDE,
};
c.bench_function("rfx_enc", |b| b.iter(|| rfx_enc(&bitmap, &quant, algo)));
}
pub fn to_ycbcr_bench(c: &mut Criterion) {
const WIDTH: usize = 64;
const HEIGHT: usize = 64;
let input = vec![0; WIDTH * HEIGHT * 4];
let stride = WIDTH * 4;
let mut y = [0i16; WIDTH * HEIGHT];
let mut cb = [0i16; WIDTH * HEIGHT];
let mut cr = [0i16; WIDTH * HEIGHT];
let format = ironrdp_graphics::image_processing::PixelFormat::ARgb32;
c.bench_function("to_ycbcr", |b| {
b.iter(|| {
to_64x64_ycbcr_tile(
&input,
WIDTH.try_into().expect("can't panic"),
HEIGHT.try_into().expect("can't panic"),
stride.try_into().expect("can't panic"),
format,
&mut y,
&mut cb,
&mut cr,
)
})
});
}
criterion_group!(benches, rfx_enc_tile_bench, rfx_enc_bench, to_ycbcr_bench);
criterion_main!(benches);

View file

@ -1,48 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.7.0...ironrdp-blocking-v0.8.0)] - 2025-12-18
### <!-- 4 -->Bug Fixes
- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a))
- Rename `AsyncNetworkClient` to `NetworkClient`
- Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch
using generics (`&mut N where N: NetworkClient`)
- Reorder `connect_finalize` parameters for consistency across crates
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.3.1...ironrdp-blocking-v0.4.0)] - 2025-03-12
### <!-- 7 -->Build
- Bump ironrdp-pdu
## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.3.0...ironrdp-blocking-v0.3.1)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.2.1...ironrdp-blocking-v0.3.0)] - 2025-01-28
### <!-- 4 -->Changed
- Remove unmatched parameter from `Framed::read_by_hint` function ([63963182b5](https://github.com/Devolutions/IronRDP/commit/63963182b5af6ad45dc638e93de4b8a0b565c7d3))
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.2.0...ironrdp-blocking-v0.2.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))

View file

@ -1,27 +0,0 @@
[package]
name = "ironrdp-blocking"
version = "0.8.0"
readme = "README.md"
description = "Blocking I/O abstraction wrapping the IronRDP state machines conveniently"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public
tracing = { version = "0.1", features = ["log"] }
bytes = "1" # public
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,11 +0,0 @@
# IronRDP Blocking
Blocking I/O abstraction wrapping the IronRDP state machines conveniently.
This crate is a higher level abstraction for IronRDP state machines using blocking I/O instead of
asynchronous I/O. This results in a simpler API with fewer dependencies that may be used
instead of `ironrdp-async` when concurrency is not a requirement.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,230 +0,0 @@
use std::io::{Read, Write};
use ironrdp_connector::credssp::{CredsspProcessGenerator, CredsspSequence, KerberosConfig};
use ironrdp_connector::sspi::credssp::ClientState;
use ironrdp_connector::sspi::generator::GeneratorState;
use ironrdp_connector::sspi::network_client::NetworkClient;
use ironrdp_connector::{
general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult,
Sequence as _, ServerName, State as _,
};
use ironrdp_core::WriteBuf;
use tracing::{debug, info, instrument, trace};
use crate::framed::Framed;
#[non_exhaustive]
pub struct ShouldUpgrade;
#[instrument(skip_all)]
pub fn connect_begin<S>(framed: &mut Framed<S>, connector: &mut ClientConnector) -> ConnectorResult<ShouldUpgrade>
where
S: Sync + Read + Write,
{
let mut buf = WriteBuf::new();
info!("Begin connection procedure");
while !connector.should_perform_security_upgrade() {
single_sequence_step(framed, connector, &mut buf)?;
}
Ok(ShouldUpgrade)
}
/// # Panics
///
/// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade].
pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade {
assert!(connector.should_perform_security_upgrade());
ShouldUpgrade
}
#[non_exhaustive]
pub struct Upgraded;
#[instrument(skip_all)]
pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Upgraded {
trace!("Marked as upgraded");
connector.mark_security_upgrade_as_done();
Upgraded
}
#[instrument(skip_all)]
pub fn connect_finalize<S>(
_: Upgraded,
mut connector: ClientConnector,
framed: &mut Framed<S>,
network_client: &mut impl NetworkClient,
server_name: ServerName,
server_public_key: Vec<u8>,
kerberos_config: Option<KerberosConfig>,
) -> ConnectorResult<ConnectionResult>
where
S: Read + Write,
{
let mut buf = WriteBuf::new();
debug!("CredSSP procedure");
if connector.should_perform_credssp() {
perform_credssp_step(
&mut connector,
framed,
network_client,
&mut buf,
server_name,
server_public_key,
kerberos_config,
)?;
}
debug!("Remaining of connection sequence");
let result = loop {
single_sequence_step(framed, &mut connector, &mut buf)?;
if let ClientConnectorState::Connected { result } = connector.state {
break result;
}
};
info!("Connected with success");
Ok(result)
}
fn resolve_generator(
generator: &mut CredsspProcessGenerator<'_>,
network_client: &mut impl NetworkClient,
) -> ConnectorResult<ClientState> {
let mut state = generator.start();
loop {
match state {
GeneratorState::Suspended(request) => {
let response = network_client.send(&request).map_err(|e| {
ConnectorError::new("network client send", ironrdp_connector::ConnectorErrorKind::Credssp(e))
})?;
state = generator.resume(Ok(response));
}
GeneratorState::Completed(client_state) => {
break client_state
.map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)))
}
}
}
}
#[instrument(level = "trace", skip_all)]
fn perform_credssp_step<S>(
connector: &mut ClientConnector,
framed: &mut Framed<S>,
network_client: &mut impl NetworkClient,
buf: &mut WriteBuf,
server_name: ServerName,
server_public_key: Vec<u8>,
kerberos_config: Option<KerberosConfig>,
) -> ConnectorResult<()>
where
S: Read + Write,
{
assert!(connector.should_perform_credssp());
let selected_protocol = match connector.state {
ClientConnectorState::Credssp { selected_protocol, .. } => selected_protocol,
_ => return Err(general_err!("invalid connector state for CredSSP sequence")),
};
let (mut sequence, mut ts_request) = CredsspSequence::init(
connector.config.credentials.clone(),
connector.config.domain.as_deref(),
selected_protocol,
server_name,
server_public_key,
kerberos_config,
)?;
loop {
let client_state = {
let mut generator = sequence.process_ts_request(ts_request);
resolve_generator(&mut generator, network_client)?
}; // drop generator
buf.clear();
let written = sequence.handle_process_result(client_state, buf)?;
if let Some(response_len) = written.size() {
let response = &buf[..response_len];
trace!(response_len, "Send response");
framed
.write_all(response)
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
let Some(next_pdu_hint) = sequence.next_pdu_hint() else {
break;
};
debug!(
connector.state = connector.state.name(),
hint = ?next_pdu_hint,
"Wait for PDU"
);
let pdu = framed
.read_by_hint(next_pdu_hint)
.map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?;
trace!(length = pdu.len(), "PDU received");
if let Some(next_request) = sequence.decode_server_message(&pdu)? {
ts_request = next_request;
} else {
break;
}
}
connector.mark_credssp_as_done();
Ok(())
}
pub fn single_sequence_step<S>(
framed: &mut Framed<S>,
connector: &mut ClientConnector,
buf: &mut WriteBuf,
) -> ConnectorResult<()>
where
S: Read + Write,
{
buf.clear();
let written = if let Some(next_pdu_hint) = connector.next_pdu_hint() {
debug!(
connector.state = connector.state.name(),
hint = ?next_pdu_hint,
"Wait for PDU"
);
let pdu = framed
.read_by_hint(next_pdu_hint)
.map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?;
trace!(length = pdu.len(), "PDU received");
connector.step(&pdu, buf)?
} else {
connector.step_no_input(buf)?
};
if let Some(response_len) = written.size() {
let response = &buf[..response_len];
trace!(response_len, "Send response");
framed
.write_all(response)
.map_err(|e| ironrdp_connector::custom_err!("write all", e))?;
}
Ok(())
}

View file

@ -1,136 +0,0 @@
use std::io::{self, Read, Write};
use bytes::{Bytes, BytesMut};
use ironrdp_pdu::PduHint;
use tracing::debug;
pub struct Framed<S> {
stream: S,
buf: BytesMut,
}
impl<S> Framed<S> {
pub fn new(stream: S) -> Self {
Self::new_with_leftover(stream, BytesMut::new())
}
pub fn new_with_leftover(stream: S, leftover: BytesMut) -> Self {
Self { stream, buf: leftover }
}
pub fn into_inner(self) -> (S, BytesMut) {
(self.stream, self.buf)
}
pub fn into_inner_no_leftover(self) -> S {
let (stream, leftover) = self.into_inner();
debug_assert_eq!(leftover.len(), 0, "unexpected leftover");
stream
}
pub fn get_inner(&self) -> (&S, &BytesMut) {
(&self.stream, &self.buf)
}
pub fn get_inner_mut(&mut self) -> (&mut S, &mut BytesMut) {
(&mut self.stream, &mut self.buf)
}
pub fn peek(&self) -> &[u8] {
&self.buf
}
}
impl<S> Framed<S>
where
S: Read,
{
/// Accumulates at least `length` bytes and returns exactly `length` bytes, keeping the leftover in the internal buffer.
pub fn read_exact(&mut self, length: usize) -> io::Result<BytesMut> {
loop {
if self.buf.len() >= length {
return Ok(self.buf.split_to(length));
} else {
#[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked underflow)")]
self.buf
.reserve(length.checked_sub(self.buf.len()).expect("length > self.buf.len()"));
}
let len = self.read()?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
}
/// Reads a standard RDP PDU frame.
pub fn read_pdu(&mut self) -> io::Result<(ironrdp_pdu::Action, BytesMut)> {
loop {
// Try decoding and see if a frame has been received already
match ironrdp_pdu::find_size(self.peek()) {
Ok(Some(pdu_info)) => {
let frame = self.read_exact(pdu_info.length)?;
return Ok((pdu_info.action, frame));
}
Ok(None) => {
let len = self.read()?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
Err(e) => return Err(io::Error::other(e)),
};
}
}
/// Reads a frame using the provided PduHint.
pub fn read_by_hint(&mut self, hint: &dyn PduHint) -> io::Result<Bytes> {
loop {
match hint.find_size(self.peek()).map_err(io::Error::other)? {
Some((matched, length)) => {
let bytes = self.read_exact(length)?.freeze();
if matched {
return Ok(bytes);
} else {
debug!("Received and lost an unexpected PDU");
}
}
None => {
let len = self.read()?;
// Handle EOF
if len == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "not enough bytes"));
}
}
};
}
}
/// Reads from stream and fills internal buffer, returning how many bytes were read.
fn read(&mut self) -> io::Result<usize> {
// FIXME(perf): use read_buf (https://doc.rust-lang.org/std/io/trait.Read.html#method.read_buf)
// once its stabilized. See tracking issue for RFC 2930: https://github.com/rust-lang/rust/issues/78485
let mut read_bytes = [0u8; 1024];
let len = self.stream.read(&mut read_bytes)?;
self.buf.extend_from_slice(&read_bytes[..len]);
Ok(len)
}
}
impl<S> Framed<S>
where
S: Write,
{
/// Attempts to write an entire buffer into this `Framed`s stream.
pub fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
self.stream.write_all(buf)
}
}

View file

@ -1,9 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
mod connector;
mod framed;
mod session;
pub use self::connector::*;
pub use self::framed::*;

View file

@ -1 +0,0 @@
// TODO: active session I/O helpers? Im not yet sure we need that

View file

@ -1,23 +0,0 @@
[package]
name = "ironrdp-cfg"
version = "0.1.0"
readme = "README.md"
description = "IronRDP utilities for ironrdp-cfgstore"
publish = false # TODO: publish
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } # public
[lints]
workspace = true

View file

@ -1,7 +0,0 @@
# IronRDP Configuration
IronRDP-related utilities for ironrdp-propertyset.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,63 +0,0 @@
// QUESTION: consider auto-generating this file based on a reference file?
// https://gist.github.com/awakecoding/838c7fe2ed3a6208e3ca5d8af25363f6
use ironrdp_propertyset::PropertySet;
pub trait PropertySetExt {
fn full_address(&self) -> Option<&str>;
fn server_port(&self) -> Option<i64>;
fn alternate_full_address(&self) -> Option<&str>;
fn gateway_hostname(&self) -> Option<&str>;
fn remote_application_name(&self) -> Option<&str>;
fn remote_application_program(&self) -> Option<&str>;
fn kdc_proxy_url(&self) -> Option<&str>;
fn username(&self) -> Option<&str>;
/// Target RDP server password - use for testing only
fn clear_text_password(&self) -> Option<&str>;
}
impl PropertySetExt for PropertySet {
fn full_address(&self) -> Option<&str> {
self.get::<&str>("full address")
}
fn server_port(&self) -> Option<i64> {
self.get::<i64>("server port")
}
fn alternate_full_address(&self) -> Option<&str> {
self.get::<&str>("alternate full address")
}
fn gateway_hostname(&self) -> Option<&str> {
self.get::<&str>("gatewayhostname")
}
fn remote_application_name(&self) -> Option<&str> {
self.get::<&str>("remoteapplicationname")
}
fn remote_application_program(&self) -> Option<&str> {
self.get::<&str>("remoteapplicationprogram")
}
fn kdc_proxy_url(&self) -> Option<&str> {
self.get::<&str>("kdcproxyurl")
}
fn username(&self) -> Option<&str> {
self.get::<&str>("username")
}
fn clear_text_password(&self) -> Option<&str> {
self.get::<&str>("ClearTextPassword")
}
}

View file

@ -1,49 +0,0 @@
[package]
name = "ironrdp-client-glutin"
version = "0.1.0"
readme = "README.md"
description = "GPU-accelerated RDP client using glutin"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[features]
default = ["rustls"]
rustls = ["ironrdp-tls/rustls"]
native-tls = ["ironrdp-tls/native-tls"]
[dependencies]
# Protocols
ironrdp.workspace = true
ironrdp-tls.workspace = true
sspi = { workspace = true, features = ["network_client"] }
# CLI
clap = { version = "4.2", features = ["derive", "cargo"] }
exitcode = "1.1"
# logging
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# async, futures
tokio = { version = "1", features = ["full"]}
tokio-util = { version = "0.7", features = ["compat"] }
futures-util = "0.3"
# Utils
chrono = "0.4"
anyhow = "1.0"
# GUI
glutin = "0.29"
ironrdp-glutin-renderer = { path = "../glutin-renderer"}
[lints]
workspace = true

View file

@ -1,13 +0,0 @@
# GUI client
1. An experimental GUI based of glutin and glow library.
2. Sample command to run the ui client:
```
cargo run --bin ironrdp-gui-client -- -u SimpleUsername -p SimplePassword! --avc444 --thin-client --small-cache --capabilities 0xf 192.168.1.100:3389
```
3. If the GUI has artifacts it can be dumped to a file using the gfx_dump_file parameter. Later the ironrdp-replay-client binary can be used to debug and fix any issues
in the renderer.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,186 +0,0 @@
use std::num::ParseIntError;
use std::path::PathBuf;
use clap::clap_derive::ValueEnum;
use clap::{crate_name, Parser};
use ironrdp::session::{GraphicsConfig, InputConfig};
use sspi::AuthIdentity;
const DEFAULT_WIDTH: u16 = 1920;
const DEFAULT_HEIGHT: u16 = 1080;
const GLOBAL_CHANNEL_NAME: &str = "GLOBAL";
const USER_CHANNEL_NAME: &str = "USER";
pub struct Config {
pub log_file: String,
pub addr: String,
pub input: InputConfig,
pub gfx_dump_file: Option<PathBuf>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum SecurityProtocol {
Ssl,
Hybrid,
HybridEx,
}
impl SecurityProtocol {
fn parse(security_protocol: SecurityProtocol) -> ironrdp::pdu::SecurityProtocol {
match security_protocol {
SecurityProtocol::Ssl => ironrdp::pdu::SecurityProtocol::SSL,
SecurityProtocol::Hybrid => ironrdp::pdu::SecurityProtocol::HYBRID,
SecurityProtocol::HybridEx => ironrdp::pdu::SecurityProtocol::HYBRID_EX,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum KeyboardType {
IbmPcXt,
OlivettiIco,
IbmPcAt,
IbmEnhanced,
Nokia1050,
Nokia9140,
Japanese,
}
impl KeyboardType {
fn parse(keyboard_type: KeyboardType) -> ironrdp::pdu::gcc::KeyboardType {
match keyboard_type {
KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced,
KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt,
KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt,
KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco,
KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050,
KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140,
KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese,
}
}
}
fn parse_hex(input: &str) -> Result<u32, ParseIntError> {
if input.starts_with("0x") {
u32::from_str_radix(input.get(2..).unwrap_or(""), 16)
} else {
input.parse::<u32>()
}
}
/// Devolutions IronRDP client
#[derive(Parser, Debug)]
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
#[clap(version, long_about = None)]
struct Args {
/// A file with IronRDP client logs
#[clap(short, long, value_parser, default_value_t = format!("{}.log", crate_name!()))]
log_file: String,
/// An address on which the client will connect.
addr: String,
/// A target RDP server user name
#[clap(short, long, value_parser)]
username: String,
/// An optional target RDP server domain name
#[clap(short, long, value_parser)]
domain: Option<String>,
/// A target RDP server user password
#[clap(short, long, value_parser)]
password: String,
/// Specify the security protocols to use
#[clap(long, value_enum, value_parser, default_value_t = SecurityProtocol::HybridEx)]
security_protocol: SecurityProtocol,
/// The keyboard type
#[clap(long, value_enum, value_parser, default_value_t = KeyboardType::IbmEnhanced)]
keyboard_type: KeyboardType,
/// The keyboard subtype (an original equipment manufacturer-dependent value)
#[clap(long, value_parser, default_value_t = 0)]
keyboard_subtype: u32,
/// The number of function keys on the keyboard
#[clap(long, value_parser, default_value_t = 12)]
keyboard_functional_keys_count: u32,
/// The input method editor (IME) file name associated with the active input locale
#[clap(long, value_parser, default_value_t = String::from(""))]
ime_file_name: String,
/// Contains a value that uniquely identifies the client
#[clap(long, value_parser, default_value_t = String::from(""))]
dig_product_id: String,
/// Enable AVC444
#[clap(long, group = "avc")]
avc444: bool,
/// Enable H264
#[clap(long, group = "avc")]
h264: bool,
/// Enable thin client
#[clap(long)]
thin_client: bool,
/// Enable small cache
#[clap(long)]
small_cache: bool,
/// Enabled capability versions. Each bit represents enabling a capability version
/// starting from V8 to V10_7
#[clap(long, value_parser = parse_hex, default_value_t = 0)]
capabilities: u32,
/// Enables dumping the gfx stream to a file location
#[clap(long, value_parser)]
gfx_dump_file: Option<PathBuf>,
}
impl Config {
pub fn parse_args() -> Self {
let args = Args::parse();
let graphics_config = if args.avc444 || args.h264 {
Some(GraphicsConfig {
avc444: args.avc444,
h264: args.h264,
thin_client: args.thin_client,
small_cache: args.small_cache,
capabilities: args.capabilities,
})
} else {
None
};
let input = InputConfig {
credentials: AuthIdentity {
username: args.username,
password: args.password.into(),
domain: args.domain,
},
security_protocol: SecurityProtocol::parse(args.security_protocol),
keyboard_type: KeyboardType::parse(args.keyboard_type),
keyboard_subtype: args.keyboard_subtype,
keyboard_functional_keys_count: args.keyboard_functional_keys_count,
ime_file_name: args.ime_file_name,
dig_product_id: args.dig_product_id,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
global_channel_name: GLOBAL_CHANNEL_NAME.to_string(),
user_channel_name: USER_CHANNEL_NAME.to_string(),
graphics_config,
};
Self {
log_file: args.log_file,
addr: args.addr,
input,
gfx_dump_file: args.gfx_dump_file,
}
}
}

View file

@ -1,147 +0,0 @@
use std::fmt::Debug;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::{self, Arc};
use glutin::dpi::PhysicalPosition;
use glutin::event::{Event, WindowEvent};
use glutin::event_loop::ControlFlow;
use ironrdp::pdu::dvc::gfx::ServerPdu;
use ironrdp::session::{ErasedWriter, GfxHandler};
use ironrdp_glutin_renderer::renderer::Renderer;
use tokio::sync::Mutex;
use self::input::{handle_input_events, translate_input_event};
use crate::RdpError;
mod input;
#[derive(Debug, Clone)]
pub struct MessagePassingGfxHandler {
channel: SyncSender<ServerPdu>,
}
impl MessagePassingGfxHandler {
pub fn new(channel: SyncSender<ServerPdu>) -> Self {
Self { channel }
}
}
impl GfxHandler for MessagePassingGfxHandler {
fn on_message(&self, message: ServerPdu) -> Result<Option<ironrdp::pdu::dvc::gfx::ClientPdu>, RdpError> {
self.channel.send(message).map_err(|e| RdpError::Send(e.to_string()))?;
Ok(None)
}
}
pub struct UiContext {
window: glutin::ContextWrapper<glutin::NotCurrent, glutin::window::Window>,
event_loop: glutin::event_loop::EventLoop<UserEvent>,
}
impl UiContext {
fn create_ui_context(
width: i32,
height: i32,
) -> (
glutin::ContextWrapper<glutin::NotCurrent, glutin::window::Window>,
glutin::event_loop::EventLoop<UserEvent>,
) {
let event_loop = glutin::event_loop::EventLoopBuilder::with_user_event().build();
let window_builder = glutin::window::WindowBuilder::new()
.with_title("IronRDP Client")
.with_resizable(false)
.with_inner_size(glutin::dpi::PhysicalSize::new(width, height));
let window = glutin::ContextBuilder::new()
.with_vsync(true)
.build_windowed(window_builder, &event_loop)
.unwrap();
(window, event_loop)
}
pub fn new(width: u16, height: u16) -> Self {
let (window, event_loop) = UiContext::create_ui_context(width as i32, height as i32);
UiContext { window, event_loop }
}
}
#[derive(Debug)]
pub enum UserEvent {}
/// Launches the GUI. Because of the way UI programming works the event loop has to be run from main thread
pub fn launch_gui(
context: UiContext,
gfx_dump_file: Option<PathBuf>,
graphic_receiver: Receiver<ServerPdu>,
stream: Arc<Mutex<ErasedWriter>>,
) -> Result<(), RdpError> {
let (sender, receiver) = sync::mpsc::channel();
tokio::spawn(async move { handle_input_events(receiver, stream).await });
let renderer = Renderer::new(context.window, graphic_receiver, gfx_dump_file);
// We handle events differently between targets
let mut last_position: Option<PhysicalPosition<f64>> = None;
context.event_loop.run(move |main_event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match &main_event {
Event::LoopDestroyed => {}
Event::RedrawRequested(_) => {
let res = renderer.repaint();
if res.is_err() {
error!("Repaint send error: {:?}", res);
}
}
Event::WindowEvent { ref event, .. } => match event {
WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit,
WindowEvent::Resized(..) => {
// let width = new_size.width;
// let height = new_size.height;
// let scale_factor = window.window().scale_factor();
// info!("Scale factor: {} Window size: {:?}x {:?}", scale_factor, width, height);
// let layout_pdu = display::ClientPdu::DisplayControlMonitorLayout(MonitorLayoutPdu {
// monitors: vec![Monitor {
// left: 0,
// top: 0,
// width: width,
// height: height,
// flags: MonitorFlags::PRIMARY,
// physical_width: 0,
// physical_height: 0,
// orientation: Orientation::Landscape,
// desktop_scale_factor: 0,
// device_scale_factor: 0,
// }],
// });
// let mut data_buffer = Vec::new();
// layout_pdu.to_buffer(&mut data_buffer)?;
// if let (Some(x224_processor), Some(stream)) = (x224_processor.as_ref(), stream.as_mut()) {
// let mut x224_processor = x224_processor.lock()?;
// // Ignorable error in case of display channel is not connected
// let result =
// x224_processor.send_dynamic(&mut *stream, x224::RDP8_DISPLAY_PIPELINE_NAME, data_buffer);
// if result.is_err() {
// error!("Monitor layout {:?}", result);
// } else {
// error!("Monitor layout success");
// }
// }
}
WindowEvent::KeyboardInput { .. }
| WindowEvent::MouseInput { .. }
| WindowEvent::CursorMoved { .. } => {
if let Some(event) = translate_input_event(main_event, &mut last_position) {
let result = sender.send(event);
if result.is_err() {
error!("Send of event failed: {:?}", result);
}
}
}
_ => {}
},
_ => (),
}
})
}

View file

@ -1,92 +0,0 @@
use std::sync::mpsc::Receiver;
use std::sync::Arc;
use futures_util::AsyncWriteExt;
use glutin::dpi::PhysicalPosition;
use glutin::event::{ElementState, Event, WindowEvent};
use ironrdp::pdu::input::fast_path::{FastPathInput, FastPathInputEvent, KeyboardFlags};
use ironrdp::pdu::input::mouse::PointerFlags;
use ironrdp::pdu::input::MousePdu;
use ironrdp::session::ErasedWriter;
use tokio::sync::Mutex;
use super::UserEvent;
pub async fn handle_input_events(receiver: Receiver<FastPathInputEvent>, event_stream: Arc<Mutex<ErasedWriter>>) {
loop {
let mut fastpath_events = Vec::new();
let event = receiver.recv().unwrap();
fastpath_events.push(event);
while let Ok(event) = receiver.try_recv() {
fastpath_events.push(event);
}
let mut data: Vec<u8> = Vec::new();
let input_pdu = FastPathInput(fastpath_events);
input_pdu.to_buffer(&mut data).unwrap();
let mut event_stream = event_stream.lock().await;
let _result = event_stream.write_all(data.as_slice()).await;
let _result = event_stream.flush().await;
}
}
pub fn translate_input_event(
event: Event<UserEvent>,
last_position: &mut Option<PhysicalPosition<f64>>,
) -> Option<FastPathInputEvent> {
match event {
Event::WindowEvent { ref event, .. } => match event {
WindowEvent::KeyboardInput {
device_id: _,
input,
is_synthetic: _,
} => {
let scan_code = input.scancode & 0xff;
let flags = match input.state {
ElementState::Pressed => KeyboardFlags::empty(),
ElementState::Released => KeyboardFlags::RELEASE,
};
Some(FastPathInputEvent::KeyboardEvent(flags, scan_code as u8))
}
WindowEvent::MouseInput { state, button, .. } => {
if let Some(position) = last_position.as_ref() {
let button = match button {
glutin::event::MouseButton::Left => PointerFlags::LEFT_BUTTON,
glutin::event::MouseButton::Right => PointerFlags::RIGHT_BUTTON,
glutin::event::MouseButton::Middle => PointerFlags::MIDDLE_BUTTON_OR_WHEEL,
glutin::event::MouseButton::Other(_) => PointerFlags::empty(),
};
let button_events = button
| match state {
ElementState::Pressed => PointerFlags::DOWN,
ElementState::Released => PointerFlags::empty(),
};
let pdu = MousePdu {
x_position: position.x as u16,
y_position: position.y as u16,
flags: button_events,
number_of_wheel_rotation_units: 0,
};
Some(FastPathInputEvent::MouseEvent(pdu))
} else {
None
}
}
WindowEvent::CursorMoved { position, .. } => {
*last_position = Some(*position);
let pdu = MousePdu {
x_position: position.x as u16,
y_position: position.y as u16,
flags: PointerFlags::MOVE,
number_of_wheel_rotation_units: 0,
};
Some(FastPathInputEvent::MouseEvent(pdu))
}
_ => None,
},
_ => None,
}
}

View file

@ -1,258 +0,0 @@
mod config;
use std::sync::mpsc::sync_channel;
use std::sync::Arc;
use std::{io, process};
use anyhow::Context as _;
use futures_util::io::AsyncWriteExt as _;
use gui::MessagePassingGfxHandler;
use ironrdp::graphics::image_processing::PixelFormat;
use ironrdp::pdu::dvc::gfx::ServerPdu;
use ironrdp::session::connection_sequence::{process_connection_sequence, UpgradedStream};
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{ActiveStageOutput, ActiveStageProcessor, ErasedWriter, RdpError};
use sspi::network_client::reqwest_network_client::RequestClientFactory;
use tokio::io::AsyncWriteExt as _;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_util::compat::TokioAsyncReadCompatExt as _;
use x509_parser::prelude::{FromDer as _, X509Certificate};
use crate::config::Config;
#[cfg(feature = "rustls")]
type TlsStream = tokio_util::compat::Compat<tokio_rustls::client::TlsStream<TcpStream>>;
#[cfg(all(feature = "native-tls", not(feature = "rustls")))]
type TlsStream = tokio_util::compat::Compat<async_native_tls::TlsStream<TcpStream>>;
mod gui;
#[cfg(feature = "rustls")]
mod danger {
use std::time::SystemTime;
use tokio_rustls::rustls::client::ServerCertVerified;
use tokio_rustls::rustls::{Certificate, Error, ServerName};
pub struct NoCertificateVerification;
impl tokio_rustls::rustls::client::ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &Certificate,
_intermediates: &[Certificate],
_server_name: &ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: SystemTime,
) -> Result<ServerCertVerified, Error> {
Ok(tokio_rustls::rustls::client::ServerCertVerified::assertion())
}
}
}
#[tokio::main]
async fn main() {
let config = Config::parse_args();
setup_logging(config.log_file.as_str()).expect("failed to initialize logging");
let exit_code = match run(config).await {
Ok(_) => {
println!("RDP successfully finished");
exitcode::OK
}
Err(RdpError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => {
error!("{}", e);
println!("The server has terminated the RDP session");
exitcode::NOHOST
}
Err(ref e) => {
error!("{}", e);
println!("RDP failed because of {e}");
match e {
RdpError::Io(_) => exitcode::IOERR,
RdpError::Connection(_) => exitcode::NOHOST,
_ => exitcode::PROTOCOL,
}
}
};
std::process::exit(exit_code);
}
fn setup_logging(log_file: &str) -> anyhow::Result<()> {
use std::fs::OpenOptions;
use tracing::metadata::LevelFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(log_file)
.with_context(|| format!("Couldnt open {log_file}"))?;
let fmt_layer = tracing_subscriber::fmt::layer()
.compact()
.with_ansi(false)
.with_writer(file);
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.with_env_var("IRONRDP_LOG_LEVEL")
.from_env_lossy();
let reg = tracing_subscriber::registry().with(fmt_layer).with(env_filter);
tracing::subscriber::set_global_default(reg).context("Failed to set tracing global subscriber")?;
Ok(())
}
async fn run(config: Config) -> Result<(), RdpError> {
let addr = ironrdp::session::connection_sequence::Address::lookup_addr(&config.addr)?;
let stream = TcpStream::connect(addr.sock).await.map_err(RdpError::Connection)?;
let (connection_sequence_result, reader, writer) = process_connection_sequence(
stream.compat(),
&addr,
&config.input,
establish_tls,
Box::new(RequestClientFactory),
)
.await?;
let writer = Arc::new(Mutex::new(writer));
let image = DecodedImage::new(
PixelFormat::RgbA32,
connection_sequence_result.desktop_size.width,
connection_sequence_result.desktop_size.height,
);
launch_client(config, connection_sequence_result, image, reader, writer).await
}
async fn launch_client(
config: Config,
connection_sequence_result: ironrdp::session::connection_sequence::ConnectionSequenceResult,
image: DecodedImage,
reader: ironrdp::session::FramedReader,
writer: Arc<Mutex<ErasedWriter>>,
) -> Result<(), RdpError> {
let (sender, receiver) = sync_channel::<ServerPdu>(1);
let handler = MessagePassingGfxHandler::new(sender);
let active_stage = ActiveStageProcessor::new(
config.input.clone(),
Some(Box::new(handler)),
connection_sequence_result,
);
let gui = gui::UiContext::new(config.input.width, config.input.height);
let active_stage_writer = writer.clone();
let active_stage_handle = tokio::spawn(async move {
match process_active_stage(reader, active_stage, image, active_stage_writer).await {
Ok(()) => Ok(()),
Err(error) => {
error!(?error, "Active stage failed");
process::exit(-1);
}
}
});
gui::launch_gui(gui, config.gfx_dump_file, receiver, writer.clone())?;
active_stage_handle.await.map_err(|e| RdpError::Io(e.into()))?
}
async fn process_active_stage(
mut reader: ironrdp::session::FramedReader,
mut active_stage: ActiveStageProcessor,
mut image: DecodedImage,
writer: Arc<Mutex<ErasedWriter>>,
) -> Result<(), RdpError> {
'outer: loop {
let frame = reader.read_frame().await?.ok_or(RdpError::AccessDenied)?.freeze();
let outputs = active_stage.process(&mut image, frame)?;
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => {
let mut writer = writer.lock().await;
writer.write_all(&frame).await?
}
ActiveStageOutput::GraphicsUpdate(_region) => {}
ActiveStageOutput::Terminate => break 'outer,
}
}
}
Ok(())
}
// TODO: this can be refactored into a separate `ironrdp-tls` crate (all native clients will do the same TLS dance)
async fn establish_tls(stream: tokio_util::compat::Compat<TcpStream>) -> Result<UpgradedStream<TlsStream>, RdpError> {
let stream = stream.into_inner();
#[cfg(all(feature = "native-tls", not(feature = "rustls")))]
let mut tls_stream = {
let connector = async_native_tls::TlsConnector::new()
.danger_accept_invalid_certs(true)
.use_sni(false);
// domain is an empty string because client accepts IP address in the cli
match connector.connect("", stream).await {
Ok(tls) => tls,
Err(err) => return Err(RdpError::TlsHandshake(err)),
}
};
#[cfg(feature = "rustls")]
let mut tls_stream = {
let mut client_config = tokio_rustls::rustls::client::ClientConfig::builder()
.with_safe_defaults()
.with_custom_certificate_verifier(std::sync::Arc::new(danger::NoCertificateVerification))
.with_no_client_auth();
// This adds support for the SSLKEYLOGFILE env variable (https://wiki.wireshark.org/TLS#using-the-pre-master-secret)
client_config.key_log = std::sync::Arc::new(tokio_rustls::rustls::KeyLogFile::new());
let rc_config = std::sync::Arc::new(client_config);
let example_com = "stub_string".try_into().unwrap();
let connector = tokio_rustls::TlsConnector::from(rc_config);
connector.connect(example_com, stream).await?
};
tls_stream.flush().await?;
#[cfg(all(feature = "native-tls", not(feature = "rustls")))]
let server_public_key = {
let cert = tls_stream
.peer_certificate()
.map_err(RdpError::TlsConnector)?
.ok_or(RdpError::MissingPeerCertificate)?;
get_tls_peer_pubkey(cert.to_der().map_err(RdpError::DerEncode)?)?
};
#[cfg(feature = "rustls")]
let server_public_key = {
let cert = tls_stream
.get_ref()
.1
.peer_certificates()
.ok_or(RdpError::MissingPeerCertificate)?[0]
.as_ref();
get_tls_peer_pubkey(cert.to_vec())?
};
Ok(UpgradedStream {
stream: tls_stream.compat(),
server_public_key,
})
}
pub fn get_tls_peer_pubkey(cert: Vec<u8>) -> io::Result<Vec<u8>> {
let res = X509Certificate::from_der(&cert[..])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid der certificate."))?;
let public_key = res.1.tbs_certificate.subject_pki.subject_public_key;
Ok(public_key.data.to_vec())
}

View file

@ -1,94 +0,0 @@
[package]
name = "ironrdp-client"
version = "0.1.0"
readme = "README.md"
description = "Portable RDP client without GPU acceleration"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
default-run = "ironrdp-client"
# Not publishing for now.
publish = false
[lib]
doctest = false
test = false
[[bin]]
name = "ironrdp-client"
test = false
[features]
default = ["rustls"]
rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"]
native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls", "ironrdp-mstsgu/native-tls"]
qoi = ["ironrdp/qoi"]
qoiz = ["ironrdp/qoiz"]
[dependencies]
# Protocols
ironrdp = { path = "../ironrdp", version = "0.14", features = [
"session",
"input",
"graphics",
"dvc",
"svc",
"rdpdr",
"rdpsnd",
"cliprdr",
"displaycontrol",
"connector",
] }
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] }
ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" }
ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.4" }
ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" }
ironrdp-mstsgu = { path = "../ironrdp-mstsgu" }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.8", features = ["reqwest"] }
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy"
ironrdp-propertyset.path = "../ironrdp-propertyset"
ironrdp-rdpfile.path = "../ironrdp-rdpfile"
ironrdp-cfg.path = "../ironrdp-cfg"
# Windowing and rendering
winit = { version = "0.30", features = ["rwh_06"] }
softbuffer = "0.4"
# CLI
clap = { version = "4.5", features = ["derive", "cargo"] }
proc-exit = "2"
inquire = "0.9"
# Logging
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Async, futures
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7" }
tokio-tungstenite = "0.28"
transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" }
futures-util = { version = "0.3", features = ["sink"] }
# Utils
whoami = "1.6"
anyhow = "1"
smallvec = "1.15"
tap = "1"
semver = "1"
raw-window-handle = "0.6"
uuid = { version = "1.19" }
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
url = "2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Foundation"] }
[lints]
workspace = true

View file

@ -1,48 +0,0 @@
# IronRDP client
Portable RDP client without GPU acceleration.
This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using
non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering
and winit for windowing.
## Sample usage
```shell
ironrdp-client <HOSTNAME> --username <USERNAME> --password <PASSWORD>
```
## Configuring log filter directives
The `IRONRDP_LOG` environment variable is used to set the log filter directives.
```shell
IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-client <HOSTNAME> --username <USERNAME> --password <PASSWORD>
```
See [`tracing-subscriber`s documentation][tracing-doc] for more details.
[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives
## Support for `SSLKEYLOGFILE`
This client supports reading the `SSLKEYLOGFILE` environment variable.
When set, the TLS encryption secrets for the session will be dumped to the file specified
by the environment variable.
This file can be read by Wireshark so that in can decrypt the packets.
### Example
```shell
SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-client <HOSTNAME> --username <USERNAME> --password <PASSWORD>
```
### Usage in Wireshark
See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP
[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile

View file

@ -1,414 +0,0 @@
#![allow(clippy::print_stderr, clippy::print_stdout)] // allowed in this module only
use core::num::NonZeroU32;
use core::time::Duration;
use std::sync::Arc;
use std::time::Instant;
use anyhow::Context as _;
use raw_window_handle::{DisplayHandle, HasDisplayHandle as _};
use tokio::sync::mpsc;
use tracing::{debug, error, trace, warn};
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalPosition, PhysicalSize};
use winit::event::{self, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::platform::scancode::PhysicalKeyExtScancode as _;
use winit::window::{CursorIcon, CustomCursor, Window, WindowAttributes};
use crate::rdp::{RdpInputEvent, RdpOutputEvent};
type WindowSurface = (Arc<Window>, softbuffer::Surface<DisplayHandle<'static>, Arc<Window>>);
pub struct App {
input_event_sender: mpsc::UnboundedSender<RdpInputEvent>,
context: softbuffer::Context<DisplayHandle<'static>>,
window: Option<WindowSurface>,
buffer: Vec<u32>,
buffer_size: (u16, u16),
input_database: ironrdp::input::Database,
last_size: Option<PhysicalSize<u32>>,
resize_timeout: Option<Instant>,
}
impl App {
pub fn new(
event_loop: &EventLoop<RdpOutputEvent>,
input_event_sender: &mpsc::UnboundedSender<RdpInputEvent>,
) -> anyhow::Result<Self> {
// SAFETY: We drop the softbuffer context right before the event loop is stopped, thus making this safe.
// FIXME: This is not a sufficient proof and the API is actually unsound as-is.
let display_handle = unsafe {
core::mem::transmute::<DisplayHandle<'_>, DisplayHandle<'static>>(
event_loop.display_handle().context("get display handle")?,
)
};
let context = softbuffer::Context::new(display_handle)
.map_err(|e| anyhow::anyhow!("unable to initialize softbuffer context: {e}"))?;
let input_database = ironrdp::input::Database::new();
Ok(Self {
input_event_sender: input_event_sender.clone(),
context,
window: None,
buffer: Vec::new(),
buffer_size: (0, 0),
input_database,
last_size: None,
resize_timeout: None,
})
}
fn send_resize_event(&mut self) {
let Some(size) = self.last_size.take() else {
return;
};
let Some((window, _)) = self.window.as_mut() else {
return;
};
#[expect(clippy::as_conversions, reason = "casting f64 to u32")]
let scale_factor = (window.scale_factor() * 100.0) as u32;
let width = u16::try_from(size.width).expect("reasonable width");
let height = u16::try_from(size.height).expect("reasonable height");
let _ = self.input_event_sender.send(RdpInputEvent::Resize {
width,
height,
scale_factor,
// TODO: it should be possible to get the physical size here, however winit doesn't make it straightforward.
// FreeRDP does it based on DPI reading grabbed via [`SDL_GetDisplayDPI`](https://wiki.libsdl.org/SDL2/SDL_GetDisplayDPI):
// https://github.com/FreeRDP/FreeRDP/blob/ba8cf8cf2158018fb7abbedb51ab245f369be813/client/SDL/sdl_monitor.cpp#L250-L262
// See also: https://github.com/rust-windowing/winit/issues/826
physical_size: None,
});
}
fn draw(&mut self) {
if self.buffer.is_empty() {
return;
}
let Some((_, surface)) = self.window.as_mut() else {
return;
};
let mut sb_buffer = surface.buffer_mut().expect("surface buffer");
sb_buffer.copy_from_slice(self.buffer.as_slice());
sb_buffer.present().expect("buffer present");
}
}
impl ApplicationHandler<RdpOutputEvent> for App {
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if let Some(timeout) = self.resize_timeout {
if let Some(timeout) = timeout.checked_duration_since(Instant::now()) {
event_loop.set_control_flow(ControlFlow::wait_duration(timeout));
} else {
self.send_resize_event();
self.resize_timeout = None;
event_loop.set_control_flow(ControlFlow::Wait);
}
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window_attributes = WindowAttributes::default().with_title("IronRDP");
match event_loop.create_window(window_attributes) {
Ok(window) => {
let window = Arc::new(window);
let surface = softbuffer::Surface::new(&self.context, Arc::clone(&window)).expect("surface");
self.window = Some((window, surface));
}
Err(error) => {
error!(%error, "Failed to create window");
event_loop.exit();
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: winit::window::WindowId, event: WindowEvent) {
let Some((window, _)) = self.window.as_mut() else {
return;
};
if window_id != window.id() {
return;
}
match event {
WindowEvent::Resized(size) => {
self.last_size = Some(size);
self.resize_timeout = Some(Instant::now() + Duration::from_secs(1));
}
WindowEvent::CloseRequested => {
if self.input_event_sender.send(RdpInputEvent::Close).is_err() {
error!("Failed to send graceful shutdown event, closing the window");
event_loop.exit();
}
}
WindowEvent::DroppedFile(_) => {
// TODO(#110): File upload
}
// WindowEvent::ReceivedCharacter(_) => {
// Sadly, we can't use this winit event to send RDP unicode events because
// of the several reasons:
// 1. `ReceivedCharacter` event doesn't provide a way to distinguish between
// key press and key release, therefore the only way to use it is to send
// a key press + release events sequentially, which will not allow to
// handle long press and key repeat events.
// 2. This event do not fire for non-printable keys (e.g. Control, Alt, etc.)
// 3. This event fies BEFORE `KeyboardInput` event, so we can't make a
// reasonable workaround for `1` and `2` by collecting physical key press
// information first via `KeyboardInput` before processing `ReceivedCharacter`.
//
// However, all of these issues can be solved by updating `winit` to the
// newer version.
//
// TODO(#376): Update winit
// TODO(#376): Implement unicode input in native client
// }
WindowEvent::KeyboardInput { event, .. } => {
if let Some(scancode) = event.physical_key.to_scancode() {
let scancode = match u16::try_from(scancode) {
Ok(scancode) => scancode,
Err(_) => {
warn!("Unsupported scancode: `{scancode:#X}`; ignored");
return;
}
};
let scancode = ironrdp::input::Scancode::from_u16(scancode);
let operation = match event.state {
event::ElementState::Pressed => ironrdp::input::Operation::KeyPressed(scancode),
event::ElementState::Released => ironrdp::input::Operation::KeyReleased(scancode),
};
let input_events = self.input_database.apply(core::iter::once(operation));
send_fast_path_events(&self.input_event_sender, input_events);
}
}
WindowEvent::ModifiersChanged(modifiers) => {
const SHIFT_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(false, 0x2A);
const CONTROL_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(false, 0x1D);
const ALT_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(false, 0x38);
const LOGO_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(true, 0x5B);
let mut operations = smallvec::SmallVec::<[ironrdp::input::Operation; 4]>::new();
let mut add_operation = |pressed: bool, scancode: ironrdp::input::Scancode| {
let operation = if pressed {
ironrdp::input::Operation::KeyPressed(scancode)
} else {
ironrdp::input::Operation::KeyReleased(scancode)
};
operations.push(operation);
};
// NOTE: https://docs.rs/winit/0.30.12/src/winit/keyboard.rs.html#1737-1744
//
// We cant use state.lshift_state(), state.lcontrol_state(), etc, because on some platforms such as
// Linux, the modifiers change is hidden.
//
// > The exact modifier key is not used to represent modifiers state in the
// > first place due to a fact that modifiers state could be changed without any
// > key being pressed and on some platforms like Wayland/X11 which key resulted
// > in modifiers change is hidden, also, not that it really matters.
add_operation(modifiers.state().shift_key(), SHIFT_LEFT);
add_operation(modifiers.state().control_key(), CONTROL_LEFT);
add_operation(modifiers.state().alt_key(), ALT_LEFT);
add_operation(modifiers.state().super_key(), LOGO_LEFT);
let input_events = self.input_database.apply(operations);
send_fast_path_events(&self.input_event_sender, input_events);
}
WindowEvent::CursorMoved { position, .. } => {
let win_size = window.inner_size();
#[expect(clippy::as_conversions, reason = "casting f64 to u16")]
let x = (position.x / f64::from(win_size.width) * f64::from(self.buffer_size.0)) as u16;
#[expect(clippy::as_conversions, reason = "casting f64 to u16")]
let y = (position.y / f64::from(win_size.height) * f64::from(self.buffer_size.1)) as u16;
let operation = ironrdp::input::Operation::MouseMove(ironrdp::input::MousePosition { x, y });
let input_events = self.input_database.apply(core::iter::once(operation));
send_fast_path_events(&self.input_event_sender, input_events);
}
WindowEvent::MouseWheel { delta, .. } => {
let mut operations = smallvec::SmallVec::<[ironrdp::input::Operation; 2]>::new();
match delta {
event::MouseScrollDelta::LineDelta(delta_x, delta_y) => {
if delta_x.abs() > 0.001 {
operations.push(ironrdp::input::Operation::WheelRotations(
ironrdp::input::WheelRotations {
is_vertical: false,
#[expect(clippy::as_conversions, reason = "casting f32 to i16")]
rotation_units: (delta_x * 100.) as i16,
},
));
}
if delta_y.abs() > 0.001 {
operations.push(ironrdp::input::Operation::WheelRotations(
ironrdp::input::WheelRotations {
is_vertical: true,
#[expect(clippy::as_conversions, reason = "casting f32 to i16")]
rotation_units: (delta_y * 100.) as i16,
},
));
}
}
event::MouseScrollDelta::PixelDelta(delta) => {
if delta.x.abs() > 0.001 {
operations.push(ironrdp::input::Operation::WheelRotations(
ironrdp::input::WheelRotations {
is_vertical: false,
#[expect(clippy::as_conversions, reason = "casting f64 to i16")]
rotation_units: delta.x as i16,
},
));
}
if delta.y.abs() > 0.001 {
operations.push(ironrdp::input::Operation::WheelRotations(
ironrdp::input::WheelRotations {
is_vertical: true,
#[expect(clippy::as_conversions, reason = "casting f64 to i16")]
rotation_units: delta.y as i16,
},
));
}
}
};
let input_events = self.input_database.apply(operations);
send_fast_path_events(&self.input_event_sender, input_events);
}
WindowEvent::MouseInput { state, button, .. } => {
let mouse_button = match button {
event::MouseButton::Left => ironrdp::input::MouseButton::Left,
event::MouseButton::Right => ironrdp::input::MouseButton::Right,
event::MouseButton::Middle => ironrdp::input::MouseButton::Middle,
event::MouseButton::Back => ironrdp::input::MouseButton::X1,
event::MouseButton::Forward => ironrdp::input::MouseButton::X2,
event::MouseButton::Other(native_button) => {
if let Some(button) = ironrdp::input::MouseButton::from_native_button(native_button) {
button
} else {
return;
}
}
};
let operation = match state {
event::ElementState::Pressed => ironrdp::input::Operation::MouseButtonPressed(mouse_button),
event::ElementState::Released => ironrdp::input::Operation::MouseButtonReleased(mouse_button),
};
let input_events = self.input_database.apply(core::iter::once(operation));
send_fast_path_events(&self.input_event_sender, input_events);
}
WindowEvent::RedrawRequested => {
self.draw();
}
WindowEvent::ActivationTokenDone { .. }
| WindowEvent::Moved(_)
| WindowEvent::Destroyed
| WindowEvent::HoveredFile(_)
| WindowEvent::HoveredFileCancelled
| WindowEvent::Focused(_)
| WindowEvent::Ime(_)
| WindowEvent::CursorEntered { .. }
| WindowEvent::CursorLeft { .. }
| WindowEvent::PinchGesture { .. }
| WindowEvent::PanGesture { .. }
| WindowEvent::DoubleTapGesture { .. }
| WindowEvent::RotationGesture { .. }
| WindowEvent::TouchpadPressure { .. }
| WindowEvent::AxisMotion { .. }
| WindowEvent::Touch(_)
| WindowEvent::ScaleFactorChanged { .. }
| WindowEvent::ThemeChanged(_)
| WindowEvent::Occluded(_) => {
// ignore
}
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: RdpOutputEvent) {
let Some((window, surface)) = self.window.as_mut() else {
return;
};
match event {
RdpOutputEvent::Image { buffer, width, height } => {
trace!(width = ?width, height = ?height, "Received image with size");
trace!(window_physical_size = ?window.inner_size(), "Drawing image to the window with size");
self.buffer_size = (width.get(), height.get());
self.buffer = buffer;
surface
.resize(NonZeroU32::from(width), NonZeroU32::from(height))
.expect("surface resize");
window.request_redraw();
}
RdpOutputEvent::ConnectionFailure(error) => {
error!(?error);
eprintln!("Connection error: {}", error.report());
// TODO set proc_exit::sysexits::PROTOCOL_ERR.as_raw());
event_loop.exit();
}
RdpOutputEvent::Terminated(result) => {
let _exit_code = match result {
Ok(reason) => {
println!("Terminated gracefully: {reason}");
proc_exit::sysexits::OK
}
Err(error) => {
error!(?error);
eprintln!("Active session error: {}", error.report());
proc_exit::sysexits::PROTOCOL_ERR
}
};
// TODO set exit_code.as_raw());
event_loop.exit();
}
RdpOutputEvent::PointerHidden => {
window.set_cursor_visible(false);
}
RdpOutputEvent::PointerDefault => {
window.set_cursor(CursorIcon::default());
window.set_cursor_visible(true);
}
RdpOutputEvent::PointerPosition { x, y } => {
if let Err(error) = window.set_cursor_position(LogicalPosition::new(x, y)) {
error!(?error, "Failed to set cursor position");
}
}
RdpOutputEvent::PointerBitmap(pointer) => {
debug!(width = ?pointer.width, height = ?pointer.height, "Received pointer bitmap");
match CustomCursor::from_rgba(
pointer.bitmap_data.clone(),
pointer.width,
pointer.height,
pointer.hotspot_x,
pointer.hotspot_y,
) {
Ok(cursor) => window.set_cursor(event_loop.create_custom_cursor(cursor)),
Err(error) => error!(?error, "Failed to set cursor bitmap"),
}
window.set_cursor_visible(true);
}
}
}
}
fn send_fast_path_events(
input_event_sender: &mpsc::UnboundedSender<RdpInputEvent>,
input_events: smallvec::SmallVec<[ironrdp::pdu::input::fast_path::FastPathInputEvent; 2]>,
) {
if !input_events.is_empty() {
let _ = input_event_sender.send(RdpInputEvent::FastPath(input_events));
}
}

View file

@ -1,25 +0,0 @@
use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy};
use tokio::sync::mpsc;
use tracing::error;
use crate::rdp::RdpInputEvent;
/// Shim for sending and receiving CLIPRDR events as `RdpInputEvent`
#[derive(Clone, Debug)]
pub struct ClientClipboardMessageProxy {
tx: mpsc::UnboundedSender<RdpInputEvent>,
}
impl ClientClipboardMessageProxy {
pub fn new(tx: mpsc::UnboundedSender<RdpInputEvent>) -> Self {
Self { tx }
}
}
impl ClipboardMessageProxy for ClientClipboardMessageProxy {
fn send_clipboard_message(&self, message: ClipboardMessage) {
if self.tx.send(RdpInputEvent::Clipboard(message)).is_err() {
error!("Failed to send os clipboard message, receiver is closed");
}
}
}

View file

@ -1,483 +0,0 @@
#![allow(clippy::print_stdout)]
use core::num::ParseIntError;
use core::str::FromStr;
use std::path::PathBuf;
use anyhow::Context as _;
use clap::clap_derive::ValueEnum;
use clap::Parser;
use ironrdp::connector::{self, Credentials};
use ironrdp::pdu::rdp::capability_sets::{client_codecs_capabilities, MajorPlatformType};
use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
use ironrdp_mstsgu::GwConnectTarget;
use tap::prelude::*;
use url::Url;
const DEFAULT_WIDTH: u16 = 1920;
const DEFAULT_HEIGHT: u16 = 1080;
#[derive(Clone, Debug)]
pub struct Config {
pub log_file: Option<String>,
pub gw: Option<GwConnectTarget>,
pub destination: Destination,
pub connector: connector::Config,
pub clipboard_type: ClipboardType,
pub rdcleanpath: Option<RDCleanPathConfig>,
/// DVC channel <-> named pipe proxy configuration.
///
/// Each configured proxy enables IronRDP to connect to DVC channel and create a named pipe
/// server, which will be used for proxying DVC messages to/from user-defined DVC logic
/// implemented as named pipe clients (either in the same process or in a different process).
pub dvc_pipe_proxies: Vec<DvcProxyInfo>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum ClipboardType {
Default,
Stub,
#[cfg(windows)]
Windows,
None,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum KeyboardType {
IbmPcXt,
OlivettiIco,
IbmPcAt,
IbmEnhanced,
Nokia1050,
Nokia9140,
Japanese,
}
impl KeyboardType {
fn parse(keyboard_type: KeyboardType) -> ironrdp::pdu::gcc::KeyboardType {
match keyboard_type {
KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced,
KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt,
KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt,
KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco,
KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050,
KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140,
KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese,
}
}
}
fn parse_hex(input: &str) -> Result<u32, ParseIntError> {
if input.starts_with("0x") {
u32::from_str_radix(input.get(2..).unwrap_or(""), 16)
} else {
input.parse::<u32>()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Destination {
name: String,
port: u16,
}
impl Destination {
pub fn new(addr: impl Into<String>) -> anyhow::Result<Self> {
const RDP_DEFAULT_PORT: u16 = 3389;
let addr = addr.into();
if let Some(addr_split) = addr.rsplit_once(':') {
if let Ok(sock_addr) = addr.parse::<core::net::SocketAddr>() {
Ok(Self {
name: sock_addr.ip().to_string(),
port: sock_addr.port(),
})
} else if addr.parse::<core::net::Ipv6Addr>().is_ok() {
Ok(Self {
name: addr,
port: RDP_DEFAULT_PORT,
})
} else {
Ok(Self {
name: addr_split.0.to_owned(),
port: addr_split.1.parse().context("invalid port")?,
})
}
} else {
Ok(Self {
name: addr,
port: RDP_DEFAULT_PORT,
})
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn port(&self) -> u16 {
self.port
}
}
impl FromStr for Destination {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl From<Destination> for connector::ServerName {
fn from(value: Destination) -> Self {
Self::new(value.name)
}
}
impl From<&Destination> for connector::ServerName {
fn from(value: &Destination) -> Self {
Self::new(&value.name)
}
}
#[derive(Clone, Debug)]
pub struct RDCleanPathConfig {
pub url: Url,
pub auth_token: String,
}
#[derive(Clone, Debug)]
pub struct DvcProxyInfo {
pub channel_name: String,
pub pipe_name: String,
}
impl FromStr for DvcProxyInfo {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('=');
let channel_name = parts
.next()
.ok_or_else(|| anyhow::anyhow!("missing DVC channel name"))?
.to_owned();
let pipe_name = parts
.next()
.ok_or_else(|| anyhow::anyhow!("missing DVC proxy pipe name"))?
.to_owned();
Ok(Self {
channel_name,
pipe_name,
})
}
}
/// Devolutions IronRDP client
#[derive(Parser, Debug)]
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
#[clap(version, long_about = None)]
struct Args {
/// A file with IronRDP client logs
#[clap(short, long, value_parser)]
log_file: Option<String>,
#[clap(long, value_parser)]
gw_endpoint: Option<String>,
#[clap(long, value_parser)]
gw_user: Option<String>,
#[clap(long, value_parser)]
gw_pass: Option<String>,
/// An address on which the client will connect.
destination: Option<Destination>,
/// Path to a .rdp file to read the configuration from.
#[clap(long)]
rdp_file: Option<PathBuf>,
/// A target RDP server user name
#[clap(short, long)]
username: Option<String>,
/// An optional target RDP server domain name
#[clap(short, long)]
domain: Option<String>,
/// A target RDP server user password
#[clap(short, long)]
password: Option<String>,
/// Proxy URL to connect to for the RDCleanPath
#[clap(long, requires("rdcleanpath_token"))]
rdcleanpath_url: Option<Url>,
/// Authentication token to insert in the RDCleanPath packet
#[clap(long, requires("rdcleanpath_url"))]
rdcleanpath_token: Option<String>,
/// The keyboard type
#[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)]
keyboard_type: KeyboardType,
/// The keyboard subtype (an original equipment manufacturer-dependent value)
#[clap(long, default_value_t = 0)]
keyboard_subtype: u32,
/// The number of function keys on the keyboard
#[clap(long, default_value_t = 12)]
keyboard_functional_keys_count: u32,
/// The input method editor (IME) file name associated with the active input locale
#[clap(long, default_value_t = String::from(""))]
ime_file_name: String,
/// Contains a value that uniquely identifies the client
#[clap(long, default_value_t = String::from(""))]
dig_product_id: String,
/// Enable thin client
#[clap(long)]
thin_client: bool,
/// Enable small cache
#[clap(long)]
small_cache: bool,
/// Set required color depth. Currently only 32 and 16 bit color depths are supported
#[clap(long)]
color_depth: Option<u32>,
/// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the
/// client could skip costly software rendering of the pointer with alpha blending
#[clap(long)]
no_server_pointer: bool,
/// Enabled capability versions. Each bit represents enabling a capability version
/// starting from V8 to V10_7
#[clap(long, value_parser = parse_hex, default_value_t = 0)]
capabilities: u32,
/// Automatically logon to the server by passing the INFO_AUTOLOGON flag
///
/// This flag is ignored if CredSSP authentication is used.
/// You can use `--no-credssp` to ensure its not.
#[clap(long)]
autologon: bool,
/// Disable TLS + Graphical login (legacy authentication method)
///
/// Disabling this in order to enforce usage of CredSSP (NLA) is recommended.
#[clap(long)]
no_tls: bool,
/// Disable TLS + Network Level Authentication (NLA) using CredSSP
///
/// NLA is used to authenticates RDP clients and servers before sending credentials over the network.
/// Its not recommended to disable this.
#[clap(long, alias = "no-nla")]
no_credssp: bool,
/// The clipboard type
#[clap(long, value_enum, default_value_t = ClipboardType::Default)]
clipboard_type: ClipboardType,
/// The bitmap codecs to use (remotefx:on, ...)
#[clap(long, num_args = 1.., value_delimiter = ',')]
codecs: Vec<String>,
/// Add DVC channel named pipe proxy
///
/// The format is `<name>=<pipe>`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel,
/// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix).
/// `<pipe>` will automatically be prefixed with `\\.\pipe\` on Windows.
#[clap(long)]
dvc_proxy: Vec<DvcProxyInfo>,
}
impl Config {
pub fn parse_args() -> anyhow::Result<Self> {
use ironrdp_cfg::PropertySetExt as _;
let args = Args::parse();
let mut properties = ironrdp_propertyset::PropertySet::new();
if let Some(rdp_file) = args.rdp_file {
let input =
std::fs::read_to_string(&rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?;
if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) {
for e in errors {
#[expect(clippy::print_stderr)]
{
eprintln!("Error when reading {}: {e}", rdp_file.display())
}
}
}
}
let mut gw: Option<GwConnectTarget> = None;
if let Some(gw_addr) = args.gw_endpoint {
gw = Some(GwConnectTarget {
gw_endpoint: gw_addr,
gw_user: String::new(),
gw_pass: String::new(),
server: String::new(), // TODO: non-standard port? also dont use here?
});
}
if let Some(ref mut gw) = gw {
gw.gw_user = if let Some(gw_user) = args.gw_user {
gw_user
} else {
inquire::Text::new("Gateway username:")
.prompt()
.context("Username prompt")?
};
gw.gw_pass = if let Some(gw_pass) = args.gw_pass {
gw_pass
} else {
inquire::Password::new("Gateway password:")
.without_confirmation()
.prompt()
.context("Password prompt")?
};
};
let destination = if let Some(destination) = args.destination {
destination
} else if let Some(destination) = properties.full_address() {
if let Some(port) = properties.server_port() {
format!("{destination}:{port}").parse()
} else {
destination.parse()
}
.context("invalid destination")?
} else {
inquire::Text::new("Server address:")
.prompt()
.context("Address prompt")?
.pipe(Destination::new)?
};
if let Some(ref mut gw) = gw {
gw.server = destination.name.clone(); // TODO
}
let username = if let Some(username) = args.username {
username
} else if let Some(username) = properties.username() {
username.to_owned()
} else {
inquire::Text::new("Username:").prompt().context("Username prompt")?
};
let password = if let Some(password) = args.password {
password
} else if let Some(password) = properties.clear_text_password() {
password.to_owned()
} else {
inquire::Password::new("Password:")
.without_confirmation()
.prompt()
.context("Password prompt")?
};
let codecs: Vec<_> = args.codecs.iter().map(|s| s.as_str()).collect();
let codecs = match client_codecs_capabilities(&codecs) {
Ok(codecs) => codecs,
Err(help) => {
print!("{help}");
std::process::exit(0);
}
};
let mut bitmap = connector::BitmapConfig {
color_depth: 32,
lossy_compression: true,
codecs,
};
if let Some(color_depth) = args.color_depth {
if color_depth != 16 && color_depth != 32 {
anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported.");
}
bitmap.color_depth = color_depth;
};
let clipboard_type = if args.clipboard_type == ClipboardType::Default {
#[cfg(windows)]
{
ClipboardType::Windows
}
#[cfg(not(windows))]
{
ClipboardType::None
}
} else {
args.clipboard_type
};
let connector = connector::Config {
credentials: Credentials::UsernamePassword { username, password },
domain: args.domain,
enable_tls: !args.no_tls,
enable_credssp: !args.no_credssp,
keyboard_type: KeyboardType::parse(args.keyboard_type),
keyboard_subtype: args.keyboard_subtype,
keyboard_layout: 0, // the server SHOULD use the default active input locale identifier
keyboard_functional_keys_count: args.keyboard_functional_keys_count,
ime_file_name: args.ime_file_name,
dig_product_id: args.dig_product_id,
desktop_size: connector::DesktopSize {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
},
desktop_scale_factor: 0, // Default to 0 per FreeRDP
bitmap: Some(bitmap),
client_build: semver::Version::parse(env!("CARGO_PKG_VERSION"))
.map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch)
.pipe(u32::try_from)
.context("cargo package version")?,
client_name: whoami::fallible::hostname().unwrap_or_else(|_| "ironrdp".to_owned()),
// NOTE: hardcode this value like in freerdp
// https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70
client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(),
platform: match whoami::platform() {
whoami::Platform::Windows => MajorPlatformType::WINDOWS,
whoami::Platform::Linux => MajorPlatformType::UNIX,
whoami::Platform::MacOS => MajorPlatformType::MACINTOSH,
whoami::Platform::Ios => MajorPlatformType::IOS,
whoami::Platform::Android => MajorPlatformType::ANDROID,
_ => MajorPlatformType::UNSPECIFIED,
},
hardware_id: None,
license_cache: None,
enable_server_pointer: !args.no_server_pointer,
autologon: args.autologon,
enable_audio_playback: true,
request_data: None,
pointer_software_rendering: false,
performance_flags: PerformanceFlags::default(),
timezone_info: TimezoneInfo::default(),
};
let rdcleanpath = args
.rdcleanpath_url
.zip(args.rdcleanpath_token)
.map(|(url, auth_token)| RDCleanPathConfig { url, auth_token });
Ok(Self {
log_file: args.log_file,
gw,
destination,
connector,
clipboard_type,
rdcleanpath,
dvc_pipe_proxies: args.dvc_proxy,
})
}
}

View file

@ -1,17 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary
// No need to be as strict as in production libraries
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_sign_loss)]
pub mod app;
pub mod clipboard;
pub mod config;
pub mod rdp;
mod ws;

View file

@ -1,122 +0,0 @@
#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary
use anyhow::Context as _;
use ironrdp_client::app::App;
use ironrdp_client::config::{ClipboardType, Config};
use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent};
use tokio::runtime;
use tracing::debug;
use winit::event_loop::EventLoop;
fn main() -> anyhow::Result<()> {
let mut config = Config::parse_args().context("CLI arguments parsing")?;
setup_logging(config.log_file.as_deref()).context("unable to initialize logging")?;
debug!("Initialize App");
let event_loop = EventLoop::<RdpOutputEvent>::with_user_event().build()?;
let event_loop_proxy = event_loop.create_proxy();
let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel();
let mut app = App::new(&event_loop, &input_event_sender).context("unable to initialize App")?;
// TODO: get window size & scale factor from GUI/App
let window_size = (1024, 768);
config.connector.desktop_scale_factor = 0;
config.connector.desktop_size.width = window_size.0;
config.connector.desktop_size.height = window_size.1;
let rt = runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("unable to create tokio runtime")?;
// NOTE: we need to keep `win_clipboard` alive, otherwise it will be dropped before IronRDP
// starts and clipboard functionality will not be available.
#[cfg(windows)]
let _win_clipboard;
let cliprdr_factory = match config.clipboard_type {
ClipboardType::Stub => {
use ironrdp_cliprdr_native::StubClipboard;
let cliprdr = StubClipboard::new();
let factory = cliprdr.backend_factory();
Some(factory)
}
#[cfg(windows)]
ClipboardType::Windows => {
use ironrdp_client::clipboard::ClientClipboardMessageProxy;
use ironrdp_cliprdr_native::WinClipboard;
let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?;
let factory = cliprdr.backend_factory();
_win_clipboard = cliprdr;
Some(factory)
}
_ => None,
};
let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender);
let client = RdpClient {
config,
event_loop_proxy,
input_event_receiver,
cliprdr_factory,
dvc_pipe_proxy_factory,
};
debug!("Start RDP thread");
std::thread::spawn(move || {
rt.block_on(client.run());
});
debug!("Run App");
event_loop.run_app(&mut app)?;
Ok(())
}
fn setup_logging(log_file: Option<&str>) -> anyhow::Result<()> {
use std::fs::OpenOptions;
use tracing::metadata::LevelFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.with_env_var("IRONRDP_LOG")
.from_env_lossy();
if let Some(log_file) = log_file {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(log_file)
.with_context(|| format!("couldn't open {log_file}"))?;
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(file)
.compact();
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.try_init()
.context("failed to set tracing global subscriber")?;
} else {
let fmt_layer = tracing_subscriber::fmt::layer()
.compact()
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_target(false);
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.try_init()
.context("failed to set tracing global subscriber")?;
};
Ok(())
}

View file

@ -1,691 +0,0 @@
use core::num::NonZeroU16;
use std::sync::Arc;
use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory};
use ironrdp::connector::connection_activation::ConnectionActivationState;
use ironrdp::connector::{ConnectionResult, ConnectorResult};
use ironrdp::displaycontrol::client::DisplayControlClient;
use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
use ironrdp::graphics::image_processing::PixelFormat;
use ironrdp::graphics::pointer::DecodedPointer;
use ironrdp::pdu::input::fast_path::FastPathInputEvent;
use ironrdp::pdu::{pdu_other_err, PduResult};
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{fast_path, ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult};
use ironrdp::svc::SvcMessage;
use ironrdp::{cliprdr, connector, rdpdr, rdpsnd, session};
use ironrdp_core::WriteBuf;
use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy;
use ironrdp_rdpsnd_native::cpal;
use ironrdp_tokio::reqwest::ReqwestNetworkClient;
use ironrdp_tokio::{single_sequence_step_read, split_tokio_framed, FramedWrite};
use rdpdr::NoopRdpdrBackend;
use smallvec::SmallVec;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
use winit::event_loop::EventLoopProxy;
use crate::config::{Config, RDCleanPathConfig};
#[derive(Debug)]
pub enum RdpOutputEvent {
Image {
buffer: Vec<u32>,
width: NonZeroU16,
height: NonZeroU16,
},
ConnectionFailure(connector::ConnectorError),
PointerDefault,
PointerHidden,
PointerPosition {
x: u16,
y: u16,
},
PointerBitmap(Arc<DecodedPointer>),
Terminated(SessionResult<GracefulDisconnectReason>),
}
#[derive(Debug)]
pub enum RdpInputEvent {
Resize {
width: u16,
height: u16,
scale_factor: u32,
/// The physical size of the display in millimeters (width, height).
physical_size: Option<(u32, u32)>,
},
FastPath(SmallVec<[FastPathInputEvent; 2]>),
Close,
Clipboard(ClipboardMessage),
SendDvcMessages {
channel_id: u32,
messages: Vec<SvcMessage>,
},
}
impl RdpInputEvent {
pub fn create_channel() -> (mpsc::UnboundedSender<Self>, mpsc::UnboundedReceiver<Self>) {
mpsc::unbounded_channel()
}
}
pub struct DvcPipeProxyFactory {
rdp_input_sender: mpsc::UnboundedSender<RdpInputEvent>,
}
impl DvcPipeProxyFactory {
pub fn new(rdp_input_sender: mpsc::UnboundedSender<RdpInputEvent>) -> Self {
Self { rdp_input_sender }
}
pub fn create(&self, channel_name: String, pipe_name: String) -> DvcNamedPipeProxy {
let rdp_input_sender = self.rdp_input_sender.clone();
DvcNamedPipeProxy::new(&channel_name, &pipe_name, move |channel_id, messages| {
rdp_input_sender
.send(RdpInputEvent::SendDvcMessages { channel_id, messages })
.map_err(|_error| pdu_other_err!("send DVC messages to the event loop",))?;
Ok(())
})
}
}
pub type WriteDvcMessageFn = Box<dyn Fn(u32, SvcMessage) -> PduResult<()> + Send + 'static>;
pub struct RdpClient {
pub config: Config,
pub event_loop_proxy: EventLoopProxy<RdpOutputEvent>,
pub input_event_receiver: mpsc::UnboundedReceiver<RdpInputEvent>,
pub cliprdr_factory: Option<Box<dyn CliprdrBackendFactory + Send>>,
pub dvc_pipe_proxy_factory: DvcPipeProxyFactory,
}
impl RdpClient {
pub async fn run(mut self) {
loop {
let (connection_result, framed) = if let Some(rdcleanpath) = self.config.rdcleanpath.as_ref() {
match connect_ws(
&self.config,
rdcleanpath,
self.cliprdr_factory.as_deref(),
&self.dvc_pipe_proxy_factory,
)
.await
{
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
break;
}
}
} else {
match connect(
&self.config,
self.cliprdr_factory.as_deref(),
&self.dvc_pipe_proxy_factory,
)
.await
{
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
break;
}
}
};
match active_session(
framed,
connection_result,
&self.event_loop_proxy,
&mut self.input_event_receiver,
)
.await
{
Ok(RdpControlFlow::ReconnectWithNewSize { width, height }) => {
self.config.connector.desktop_size.width = width;
self.config.connector.desktop_size.height = height;
}
Ok(RdpControlFlow::TerminatedGracefully(reason)) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Ok(reason)));
break;
}
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Err(e)));
break;
}
}
}
}
}
enum RdpControlFlow {
ReconnectWithNewSize { width: u16, height: u16 },
TerminatedGracefully(GracefulDisconnectReason),
}
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T> AsyncReadWrite for T where T: AsyncRead + AsyncWrite {}
type UpgradedFramed = ironrdp_tokio::TokioFramed<Box<dyn AsyncReadWrite + Unpin + Send + Sync>>;
async fn connect(
config: &Config,
cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>,
dvc_pipe_proxy_factory: &DvcPipeProxyFactory,
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let dest = format!("{}:{}", config.destination.name(), config.destination.port());
let (client_addr, stream) = if let Some(ref gw_config) = config.gw {
let (gw, client_addr) = ironrdp_mstsgu::GwClient::connect(gw_config, &config.connector.client_name)
.await
.map_err(|e| connector::custom_err!("GW Connect", e))?;
(client_addr, tokio_util::either::Either::Left(gw))
} else {
let stream = TcpStream::connect(dest)
.await
.map_err(|e| connector::custom_err!("TCP connect", e))?;
let client_addr = stream
.local_addr()
.map_err(|e| connector::custom_err!("get socket local address", e))?;
(client_addr, tokio_util::either::Either::Right(stream))
};
let mut framed = ironrdp_tokio::TokioFramed::new(stream);
let mut drdynvc =
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
// Instantiate all DVC proxies
for proxy in config.dvc_pipe_proxies.iter() {
let channel_name = proxy.channel_name.clone();
let pipe_name = proxy.pipe_name.clone();
trace!(%channel_name, %pipe_name, "Creating DVC proxy");
drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name));
}
let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr)
.with_static_channel(drdynvc)
.with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new())))
.with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0));
if let Some(builder) = cliprdr_factory {
let backend = builder.build_cliprdr_backend();
let cliprdr = cliprdr::Cliprdr::new(backend);
connector.attach_static_channel(cliprdr);
}
let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?;
debug!("TLS upgrade");
// Ensure there is no leftover
let (initial_stream, leftover_bytes) = framed.into_inner();
let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name())
.await
.map_err(|e| connector::custom_err!("TLS upgrade", e))?;
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector);
let erased_stream: Box<dyn AsyncReadWrite + Unpin + Send + Sync> = Box::new(upgraded_stream);
let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes);
let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert)
.ok_or_else(|| connector::general_err!("unable to extract tls server public key"))?;
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
connector,
&mut upgraded_framed,
&mut ReqwestNetworkClient::new(),
(&config.destination).into(),
server_public_key.to_owned(),
None,
)
.await?;
debug!(?connection_result);
Ok((connection_result, upgraded_framed))
}
async fn connect_ws(
config: &Config,
rdcleanpath: &RDCleanPathConfig,
cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>,
dvc_pipe_proxy_factory: &DvcPipeProxyFactory,
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let hostname = rdcleanpath
.url
.host_str()
.ok_or_else(|| connector::general_err!("host missing from the URL"))?;
let port = rdcleanpath.url.port_or_known_default().unwrap_or(443);
let socket = TcpStream::connect((hostname, port))
.await
.map_err(|e| connector::custom_err!("TCP connect", e))?;
socket
.set_nodelay(true)
.map_err(|e| connector::custom_err!("set TCP_NODELAY", e))?;
let client_addr = socket
.local_addr()
.map_err(|e| connector::custom_err!("get socket local address", e))?;
let (ws, _) = tokio_tungstenite::client_async_tls(rdcleanpath.url.as_str(), socket)
.await
.map_err(|e| connector::custom_err!("WS connect", e))?;
let ws = crate::ws::websocket_compat(ws);
let mut framed = ironrdp_tokio::TokioFramed::new(ws);
let mut drdynvc =
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
// Instantiate all DVC proxies
for proxy in config.dvc_pipe_proxies.iter() {
let channel_name = proxy.channel_name.clone();
let pipe_name = proxy.pipe_name.clone();
trace!(%channel_name, %pipe_name, "Creating DVC proxy");
drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name));
}
let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr)
.with_static_channel(drdynvc)
.with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new())))
.with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0));
if let Some(builder) = cliprdr_factory {
let backend = builder.build_cliprdr_backend();
let cliprdr = cliprdr::Cliprdr::new(backend);
connector.attach_static_channel(cliprdr);
}
let destination = format!("{}:{}", config.destination.name(), config.destination.port());
let (upgraded, server_public_key) = connect_rdcleanpath(
&mut framed,
&mut connector,
destination,
rdcleanpath.auth_token.clone(),
None,
)
.await?;
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
connector,
&mut framed,
&mut ReqwestNetworkClient::new(),
(&config.destination).into(),
server_public_key,
None,
)
.await?;
let (ws, leftover_bytes) = framed.into_inner();
let erased_stream: Box<dyn AsyncReadWrite + Unpin + Send + Sync> = Box::new(ws);
let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes);
Ok((connection_result, upgraded_framed))
}
async fn connect_rdcleanpath<S>(
framed: &mut ironrdp_tokio::Framed<S>,
connector: &mut connector::ClientConnector,
destination: String,
proxy_auth_token: String,
pcb: Option<String>,
) -> ConnectorResult<(ironrdp_tokio::Upgraded, Vec<u8>)>
where
S: ironrdp_tokio::FramedRead + FramedWrite,
{
use ironrdp::connector::Sequence as _;
use x509_cert::der::Decode as _;
#[derive(Clone, Copy, Debug)]
struct RDCleanPathHint;
const RDCLEANPATH_HINT: RDCleanPathHint = RDCleanPathHint;
impl ironrdp::pdu::PduHint for RDCleanPathHint {
fn find_size(&self, bytes: &[u8]) -> ironrdp::core::DecodeResult<Option<(bool, usize)>> {
match ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes) {
ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } => Ok(Some((true, total_length))),
ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes => Ok(None),
ironrdp_rdcleanpath::DetectionResult::Failed => Err(ironrdp::core::other_err!(
"RDCleanPathHint",
"detection failed (invalid PDU)"
)),
}
}
}
let mut buf = WriteBuf::new();
info!("Begin connection procedure");
{
// RDCleanPath request
let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else {
return Err(connector::general_err!("invalid connector state (send request)"));
};
debug_assert!(connector.next_pdu_hint().is_none());
let written = connector.step_no_input(&mut buf)?;
let x224_pdu_len = written.size().expect("written size");
debug_assert_eq!(x224_pdu_len, buf.filled_len());
let x224_pdu = buf.filled().to_vec();
let rdcleanpath_req =
ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb)
.map_err(|e| connector::custom_err!("new RDCleanPath request", e))?;
debug!(message = ?rdcleanpath_req, "Send RDCleanPath request");
let rdcleanpath_req = rdcleanpath_req
.to_der()
.map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?;
framed
.write_all(&rdcleanpath_req)
.await
.map_err(|e| connector::custom_err!("couldn't write RDCleanPath request", e))?;
}
{
// RDCleanPath response
let rdcleanpath_res = framed
.read_by_hint(&RDCLEANPATH_HINT)
.await
.map_err(|e| connector::custom_err!("read RDCleanPath request", e))?;
let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res)
.map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?;
debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU");
let (x224_connection_response, server_cert_chain) = match rdcleanpath_res
.into_enum()
.map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))?
{
ironrdp_rdcleanpath::RDCleanPath::Request { .. } => {
return Err(connector::general_err!(
"received an unexpected RDCleanPath type (request)",
));
}
ironrdp_rdcleanpath::RDCleanPath::Response {
x224_connection_response,
server_cert_chain,
server_addr: _,
} => (x224_connection_response, server_cert_chain),
ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => {
return Err(connector::custom_err!("received an RDCleanPath error", error));
}
ironrdp_rdcleanpath::RDCleanPath::NegotiationErr {
x224_connection_response,
} => {
// Try to decode as X.224 Connection Confirm to extract negotiation failure details.
if let Ok(x224_confirm) = ironrdp_core::decode::<
ironrdp::pdu::x224::X224<ironrdp::pdu::nego::ConnectionConfirm>,
>(&x224_connection_response)
{
if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 {
// Convert to negotiation failure instead of generic RDCleanPath error.
let negotiation_failure = connector::NegotiationFailure::from(code);
return Err(connector::ConnectorError::new(
"RDP negotiation failed",
connector::ConnectorErrorKind::Negotiation(negotiation_failure),
));
}
}
// Fallback to generic error if we can't decode the negotiation failure.
return Err(connector::general_err!("received an RDCleanPath negotiation error"));
}
};
let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else {
return Err(connector::general_err!("invalid connector state (wait confirm)"));
};
debug_assert!(connector.next_pdu_hint().is_some());
buf.clear();
let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?;
debug_assert!(written.is_nothing());
let server_cert = server_cert_chain
.into_iter()
.next()
.ok_or_else(|| connector::general_err!("server cert chain missing from rdcleanpath response"))?;
let cert = x509_cert::Certificate::from_der(server_cert.as_bytes())
.map_err(|e| connector::custom_err!("server cert chain missing from rdcleanpath response", e))?;
let server_public_key = cert
.tbs_certificate
.subject_public_key_info
.subject_public_key
.as_bytes()
.ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))?
.to_owned();
let should_upgrade = ironrdp_tokio::skip_connect_begin(connector);
// At this point, proxy established the TLS session.
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector);
Ok((upgraded, server_public_key))
}
}
async fn active_session(
framed: UpgradedFramed,
connection_result: ConnectionResult,
event_loop_proxy: &EventLoopProxy<RdpOutputEvent>,
input_event_receiver: &mut mpsc::UnboundedReceiver<RdpInputEvent>,
) -> SessionResult<RdpControlFlow> {
let (mut reader, mut writer) = split_tokio_framed(framed);
let mut image = DecodedImage::new(
PixelFormat::RgbA32,
connection_result.desktop_size.width,
connection_result.desktop_size.height,
);
let mut active_stage = ActiveStage::new(connection_result);
let disconnect_reason = 'outer: loop {
let outputs = tokio::select! {
frame = reader.read_pdu() => {
let (action, payload) = frame.map_err(|e| session::custom_err!("read frame", e))?;
trace!(?action, frame_length = payload.len(), "Frame received");
active_stage.process(&mut image, action, &payload)?
}
input_event = input_event_receiver.recv() => {
let input_event = input_event.ok_or_else(|| session::general_err!("GUI is stopped"))?;
match input_event {
RdpInputEvent::Resize { width, height, scale_factor, physical_size } => {
trace!(width, height, "Resize event");
let width = u32::from(width);
let height = u32::from(height);
// TODO: Make adjust_display_size take and return width and height as u16.
// From the function's doc comment, the width and height values must be less than or equal to 8192 pixels.
// Therefore, we can remove unnecessary casts from u16 to u32 and back.
let (width, height) = MonitorLayoutEntry::adjust_display_size(width, height);
debug!(width, height, "Adjusted display size");
if let Some(response_frame) = active_stage.encode_resize(width, height, Some(scale_factor), physical_size) {
vec![ActiveStageOutput::ResponseFrame(response_frame?)]
} else {
// TODO(#271): use the "auto-reconnect cookie": https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/15b0d1c9-2891-4adb-a45e-deb4aeeeab7c
debug!("Reconnecting with new size");
let width = u16::try_from(width).expect("always in the range");
let height = u16::try_from(height).expect("always in the range");
return Ok(RdpControlFlow::ReconnectWithNewSize { width, height })
}
},
RdpInputEvent::FastPath(events) => {
trace!(?events);
active_stage.process_fastpath_input(&mut image, &events)?
}
RdpInputEvent::Close => {
active_stage.graceful_shutdown()?
}
RdpInputEvent::Clipboard(event) => {
if let Some(cliprdr) = active_stage.get_svc_processor::<cliprdr::CliprdrClient>() {
if let Some(svc_messages) = match event {
ClipboardMessage::SendInitiateCopy(formats) => {
Some(cliprdr.initiate_copy(&formats)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::SendFormatData(response) => {
Some(cliprdr.submit_format_data(response)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::SendInitiatePaste(format) => {
Some(cliprdr.initiate_paste(format)
.map_err(|e| session::custom_err!("CLIPRDR", e))?)
}
ClipboardMessage::Error(e) => {
error!("Clipboard backend error: {}", e);
None
}
} {
let frame = active_stage.process_svc_processor_messages(svc_messages)?;
// Send the messages to the server
vec![ActiveStageOutput::ResponseFrame(frame)]
} else {
// No messages to send to the server
Vec::new()
}
} else {
warn!("Clipboard event received, but Cliprdr is not available");
Vec::new()
}
}
RdpInputEvent::SendDvcMessages { channel_id, messages } => {
trace!(channel_id, ?messages, "Send DVC messages");
let frame = active_stage.encode_dvc_messages(messages)?;
vec![ActiveStageOutput::ResponseFrame(frame)]
}
}
}
};
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => writer
.write_all(&frame)
.await
.map_err(|e| session::custom_err!("write response", e))?,
ActiveStageOutput::GraphicsUpdate(_region) => {
let buffer: Vec<u32> = image
.data()
.chunks_exact(4)
.map(|pixel| {
let r = pixel[0];
let g = pixel[1];
let b = pixel[2];
u32::from_be_bytes([0, r, g, b])
})
.collect();
event_loop_proxy
.send_event(RdpOutputEvent::Image {
buffer,
width: NonZeroU16::new(image.width())
.ok_or_else(|| session::general_err!("width is zero"))?,
height: NonZeroU16::new(image.height())
.ok_or_else(|| session::general_err!("height is zero"))?,
})
.map_err(|e| session::custom_err!("event_loop_proxy", e))?;
}
ActiveStageOutput::PointerDefault => {
event_loop_proxy
.send_event(RdpOutputEvent::PointerDefault)
.map_err(|e| session::custom_err!("event_loop_proxy", e))?;
}
ActiveStageOutput::PointerHidden => {
event_loop_proxy
.send_event(RdpOutputEvent::PointerHidden)
.map_err(|e| session::custom_err!("event_loop_proxy", e))?;
}
ActiveStageOutput::PointerPosition { x, y } => {
event_loop_proxy
.send_event(RdpOutputEvent::PointerPosition { x, y })
.map_err(|e| session::custom_err!("event_loop_proxy", e))?;
}
ActiveStageOutput::PointerBitmap(pointer) => {
event_loop_proxy
.send_event(RdpOutputEvent::PointerBitmap(pointer))
.map_err(|e| session::custom_err!("event_loop_proxy", e))?;
}
ActiveStageOutput::DeactivateAll(mut connection_activation) => {
// Execute the Deactivation-Reactivation Sequence:
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432
debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence");
let mut buf = WriteBuf::new();
'activation_seq: loop {
let written = single_sequence_step_read(&mut reader, &mut *connection_activation, &mut buf)
.await
.map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e))?;
if written.size().is_some() {
writer.write_all(buf.filled()).await.map_err(|e| {
session::custom_err!("write deactivation-reactivation sequence step", e)
})?;
}
if let ConnectionActivationState::Finalized {
io_channel_id,
user_channel_id,
desktop_size,
enable_server_pointer,
pointer_software_rendering,
} = connection_activation.connection_activation_state()
{
debug!(?desktop_size, "Deactivation-Reactivation Sequence completed");
// Update image size with the new desktop size.
image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height);
// Update the active stage with the new channel IDs and pointer settings.
active_stage.set_fastpath_processor(
fast_path::ProcessorBuilder {
io_channel_id,
user_channel_id,
enable_server_pointer,
pointer_software_rendering,
}
.build(),
);
active_stage.set_enable_server_pointer(enable_server_pointer);
break 'activation_seq;
}
}
}
ActiveStageOutput::Terminate(reason) => break 'outer reason,
}
}
};
Ok(RdpControlFlow::TerminatedGracefully(disconnect_reason))
}

View file

@ -1,34 +0,0 @@
use futures_util::{Sink, SinkExt as _, Stream, StreamExt as _};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_tungstenite::tungstenite;
pub(crate) fn websocket_compat<S>(stream: S) -> impl AsyncRead + AsyncWrite + Unpin + Send + 'static
where
S: Stream<Item = Result<tungstenite::Message, tungstenite::Error>>
+ Sink<tungstenite::Message, Error = tungstenite::Error>
+ Unpin
+ Send
+ 'static,
{
let compat = stream
.filter_map(|item| {
let mapped = item
.map(|msg| match msg {
tungstenite::Message::Text(s) => Some(transport::WsReadMsg::Payload(tungstenite::Bytes::from(s))),
tungstenite::Message::Binary(data) => Some(transport::WsReadMsg::Payload(data)),
tungstenite::Message::Ping(_) | tungstenite::Message::Pong(_) => None,
tungstenite::Message::Close(_) => Some(transport::WsReadMsg::Close),
tungstenite::Message::Frame(_) => unreachable!("raw frames are never returned when reading"),
})
.transpose();
core::future::ready(mapped)
})
.with(|item| {
core::future::ready(Ok::<_, tungstenite::Error>(tungstenite::Message::Binary(
tungstenite::Bytes::from(item),
)))
});
transport::WsStream::new(compat)
}

View file

@ -1,26 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.3...ironrdp-cliprdr-format-v0.1.4)] - 2025-09-04
### <!-- 7 -->Build
- Bump png from 0.17.16 to 0.18.0 (#961) ([21fa028dff](https://github.com/Devolutions/IronRDP/commit/21fa028dffa5f9bb1498b4d48d063ea42929faf5))
## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.2...ironrdp-cliprdr-format-v0.1.3)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.1...ironrdp-cliprdr-format-v0.1.2)] - 2025-01-28
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))

View file

@ -1,23 +0,0 @@
[package]
name = "ironrdp-cliprdr-format"
version = "0.1.4"
readme = "README.md"
description = "CLIPRDR format conversion library"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } # public
png = "0.18"
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,16 +0,0 @@
# IronRDP CLIPRDR formats decoding/encoding library
This Library provides the conversion logic between RDP-specific clipboard formats and
widely used formats like PNG for images, plain string for HTML etc.
### Overflows
This crate has been audited by us and is guaranteed overflow-free on 32 and 64 bits architectures.
It would be easy to cause an overflow on a 16-bit architecture.
However, its hard to imagine an RDP client running on such machines.
Size of pointers on such architectures greatly limits the maximum size of the bitmap buffers.
Its likely the RDP client will choke on a big payload before overflowing because of this crate.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

View file

@ -1,839 +0,0 @@
use std::io::Cursor;
use ironrdp_core::{
cast_int, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor,
WriteCursor,
};
/// Maximum size of PNG image that could be placed on the clipboard.
const MAX_BUFFER_SIZE: usize = 64 * 1024 * 1024; // 64 MB
#[derive(Debug)]
pub enum BitmapError {
Decode(ironrdp_core::DecodeError),
Encode(ironrdp_core::EncodeError),
Unsupported(&'static str),
InvalidSize,
BufferTooBig,
WidthTooBig,
HeightTooBig,
PngEncode(png::EncodingError),
PngDecode(png::DecodingError),
}
impl core::fmt::Display for BitmapError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
BitmapError::Decode(_error) => write!(f, "decoding error"),
BitmapError::Encode(_error) => write!(f, "encoding error"),
BitmapError::Unsupported(s) => write!(f, "unsupported bitmap: {s}"),
BitmapError::InvalidSize => write!(f, "one of bitmap's dimensions is invalid"),
BitmapError::BufferTooBig => write!(f, "buffer size required for allocation is too big"),
BitmapError::WidthTooBig => write!(f, "image width is too big"),
BitmapError::HeightTooBig => write!(f, "image height is too big"),
BitmapError::PngEncode(_error) => write!(f, "PNG encoding error"),
BitmapError::PngDecode(_error) => write!(f, "PNG decoding error"),
}
}
}
impl core::error::Error for BitmapError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
BitmapError::Decode(error) => Some(error),
BitmapError::Encode(error) => Some(error),
BitmapError::Unsupported(_) => None,
BitmapError::InvalidSize => None,
BitmapError::BufferTooBig => None,
BitmapError::WidthTooBig => None,
BitmapError::HeightTooBig => None,
BitmapError::PngEncode(encoding_error) => Some(encoding_error),
BitmapError::PngDecode(decoding_error) => Some(decoding_error),
}
}
}
impl From<png::EncodingError> for BitmapError {
fn from(error: png::EncodingError) -> Self {
BitmapError::PngEncode(error)
}
}
impl From<png::DecodingError> for BitmapError {
fn from(error: png::DecodingError) -> Self {
BitmapError::PngDecode(error)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct BitmapCompression(u32);
#[expect(dead_code)]
impl BitmapCompression {
const RGB: Self = Self(0x0000);
const RLE8: Self = Self(0x0001);
const RLE4: Self = Self(0x0002);
const BITFIELDS: Self = Self(0x0003);
const JPEG: Self = Self(0x0004);
const PNG: Self = Self(0x0005);
const CMYK: Self = Self(0x000B);
const CMYKRLE8: Self = Self(0x000C);
const CMYKRLE4: Self = Self(0x000D);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct ColorSpace(u32);
#[expect(dead_code)]
impl ColorSpace {
const CALIBRATED_RGB: Self = Self(0x00000000);
const SRGB: Self = Self(0x73524742);
const WINDOWS: Self = Self(0x57696E20);
const PROFILE_LINKED: Self = Self(0x4C494E4B);
const PROFILE_EMBEDDED: Self = Self(0x4D424544);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct BitmapIntent(u32);
#[expect(dead_code)]
impl BitmapIntent {
const LCS_GM_ABS_COLORIMETRIC: Self = Self(0x00000008);
const LCS_GM_BUSINESS: Self = Self(0x00000001);
const LCS_GM_GRAPHICS: Self = Self(0x00000002);
const LCS_GM_IMAGES: Self = Self(0x00000004);
}
type Fxpt2Dot30 = u32; // (LONG)
#[derive(Default)]
struct Ciexyz {
x: Fxpt2Dot30,
y: Fxpt2Dot30,
z: Fxpt2Dot30,
}
#[derive(Default)]
struct CiexyzTriple {
red: Ciexyz,
green: Ciexyz,
blue: Ciexyz,
}
impl CiexyzTriple {
const NAME: &'static str = "CIEXYZTRIPLE";
const FIXED_PART_SIZE: usize = 4 * 3 * 3; // 4(LONG) * 3(xyz) * 3(red, green, blue)
}
impl<'a> Decode<'a> for CiexyzTriple {
fn decode(src: &mut ReadCursor<'a>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let red = Ciexyz {
x: src.read_u32(),
y: src.read_u32(),
z: src.read_u32(),
};
let green = Ciexyz {
x: src.read_u32(),
y: src.read_u32(),
z: src.read_u32(),
};
let blue = Ciexyz {
x: src.read_u32(),
y: src.read_u32(),
z: src.read_u32(),
};
Ok(Self { red, green, blue })
}
}
impl Encode for CiexyzTriple {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u32(self.red.x);
dst.write_u32(self.red.y);
dst.write_u32(self.red.z);
dst.write_u32(self.green.x);
dst.write_u32(self.green.y);
dst.write_u32(self.green.z);
dst.write_u32(self.blue.x);
dst.write_u32(self.blue.y);
dst.write_u32(self.blue.z);
Ok(())
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
}
}
/// Header used in `CF_DIB` formats, part of [BITMAPINFO]
///
/// We don't use the optional `bmiColors` field, because it is only relevant for bitmaps with
/// bpp < 24, which are not supported yet, therefore only fixed part of the header is implemented.
///
/// [BITMAPINFO]: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfo
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BitmapInfoHeader {
/// INVARIANT: `width.abs() <= 10_000`
width: i32,
/// INVARIANT: `height.abs() <= 10_000`
height: i32,
/// INVARIANT: `bit_count <= 32`
bit_count: u16,
compression: BitmapCompression,
size_image: u32,
x_pels_per_meter: i32,
y_pels_per_meter: i32,
clr_used: u32,
clr_important: u32,
}
impl BitmapInfoHeader {
const FIXED_PART_SIZE: usize = 4 // biSize (DWORD)
+ 4 // biWidth (LONG)
+ 4 // biHeight (LONG)
+ 2 // biPlanes (WORD)
+ 2 // biBitCount (WORD)
+ 4 // biCompression (DWORD)
+ 4 // biSizeImage (DWORD)
+ 4 // biXPelsPerMeter (LONG)
+ 4 // biYPelsPerMeter (LONG)
+ 4 // biClrUsed (DWORD)
+ 4; // biClrImportant (DWORD)
const NAME: &'static str = "BITMAPINFOHEADER";
fn encode_with_size(&self, dst: &mut WriteCursor<'_>, size: u32) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
dst.write_u32(size);
dst.write_i32(self.width);
dst.write_i32(self.height);
dst.write_u16(1); // biPlanes
dst.write_u16(self.bit_count);
dst.write_u32(self.compression.0);
dst.write_u32(self.size_image);
dst.write_i32(self.x_pels_per_meter);
dst.write_i32(self.y_pels_per_meter);
dst.write_u32(self.clr_used);
dst.write_u32(self.clr_important);
Ok(())
}
fn decode_with_size(src: &mut ReadCursor<'_>) -> DecodeResult<(Self, u32)> {
ensure_fixed_part_size!(in: src);
let size = src.read_u32();
// NOTE: .abs() could panic on i32::MIN, therefore we have a check for it first.
let width = src.read_i32();
check_invariant(width != i32::MIN && width.abs() <= 10_000)
.ok_or_else(|| invalid_field_err!("biWidth", "width is too big"))?;
let height = src.read_i32();
check_invariant(height != i32::MIN && height.abs() <= 10_000)
.ok_or_else(|| invalid_field_err!("biHeight", "height is too big"))?;
let planes = src.read_u16();
if planes != 1 {
return Err(invalid_field_err!("biPlanes", "invalid planes count"));
}
let bit_count = src.read_u16();
check_invariant(bit_count <= 32).ok_or_else(|| invalid_field_err!("biBitCount", "invalid bit count"))?;
let compression = BitmapCompression(src.read_u32());
let size_image = src.read_u32();
let x_pels_per_meter = src.read_i32();
let y_pels_per_meter = src.read_i32();
let clr_used = src.read_u32();
let clr_important = src.read_u32();
let header = Self {
width,
height,
bit_count,
compression,
size_image,
x_pels_per_meter,
y_pels_per_meter,
clr_used,
clr_important,
};
Ok((header, size))
}
// INVARIANT: output (width) <= 10_000
fn width(&self) -> u16 {
let abs = self.width.abs();
debug_assert!(abs <= 10_000);
u16::try_from(abs).expect("per the invariant on self.width, this cast is infallible")
}
// INVARIANT: output (height) <= 10_000
fn height(&self) -> u16 {
let abs = self.height.abs();
debug_assert!(abs <= 10_000);
u16::try_from(abs).expect("per the invariant on self.height, this cast is infallible")
}
fn is_bottom_up(&self) -> bool {
// When self.height is positive, the bitmap is defined as bottom-up.
self.height >= 0
}
}
impl Encode for BitmapInfoHeader {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
let size = cast_int!("biSize", Self::FIXED_PART_SIZE)?;
self.encode_with_size(dst, size)
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
}
}
impl<'a> Decode<'a> for BitmapInfoHeader {
fn decode(src: &mut ReadCursor<'a>) -> DecodeResult<Self> {
let (header, size) = Self::decode_with_size(src)?;
let size: usize = cast_int!("biSize", size)?;
if size != Self::FIXED_PART_SIZE {
return Err(invalid_field_err!("biSize", "invalid V1 bitmap info header size"));
}
Ok(header)
}
}
/// Header used in `CF_DIBV5` formats, defined as [BITMAPV5HEADER]
///
/// [BITMAPV5HEADER]: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
struct BitmapV5Header {
v1: BitmapInfoHeader,
red_mask: u32,
green_mask: u32,
blue_mask: u32,
alpha_mask: u32,
color_space: ColorSpace,
endpoints: CiexyzTriple,
gamma_red: u32,
gamma_green: u32,
gamma_blue: u32,
intent: BitmapIntent,
profile_data: u32,
profile_size: u32,
}
impl BitmapV5Header {
const FIXED_PART_SIZE: usize = BitmapInfoHeader::FIXED_PART_SIZE // BITMAPV5HEADER
+ 4 // bV5RedMask (DWORD)
+ 4 // bV5GreenMask (DWORD)
+ 4 // bV5BlueMask (DWORD)
+ 4 // bV5AlphaMask (DWORD)
+ 4 // bV5CSType (DWORD)
+ CiexyzTriple::FIXED_PART_SIZE // bV5Endpoints (CIEXYZTRIPLE)
+ 4 // bV5GammaRed (DWORD)
+ 4 // bV5GammaGreen (DWORD)
+ 4 // bV5GammaBlue (DWORD)
+ 4 // bV5Intent (DWORD)
+ 4 // bV5ProfileData (DWORD)
+ 4 // bV5ProfileSize (DWORD)
+ 4; // bV5Reserved (DWORD)
const NAME: &'static str = "BITMAPV5HEADER";
}
impl Encode for BitmapV5Header {
fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> {
ensure_fixed_part_size!(in: dst);
let size = cast_int!("biSize", Self::FIXED_PART_SIZE)?;
self.v1.encode_with_size(dst, size)?;
dst.write_u32(self.red_mask);
dst.write_u32(self.green_mask);
dst.write_u32(self.blue_mask);
dst.write_u32(self.alpha_mask);
dst.write_u32(self.color_space.0);
self.endpoints.encode(dst)?;
dst.write_u32(self.gamma_red);
dst.write_u32(self.gamma_green);
dst.write_u32(self.gamma_blue);
dst.write_u32(self.intent.0);
dst.write_u32(self.profile_data);
dst.write_u32(self.profile_size);
dst.write_u32(0);
Ok(())
}
fn name(&self) -> &'static str {
Self::NAME
}
fn size(&self) -> usize {
Self::FIXED_PART_SIZE
}
}
impl<'a> Decode<'a> for BitmapV5Header {
fn decode(src: &mut ReadCursor<'a>) -> DecodeResult<Self> {
ensure_fixed_part_size!(in: src);
let (header_v1, size) = BitmapInfoHeader::decode_with_size(src)?;
let size: usize = cast_int!("biSize", size)?;
if size != Self::FIXED_PART_SIZE {
return Err(invalid_field_err!("biSize", "invalid V5 bitmap info header size"));
}
let red_mask = src.read_u32();
let green_mask = src.read_u32();
let blue_mask = src.read_u32();
let alpha_mask = src.read_u32();
let color_space_type = ColorSpace(src.read_u32());
let endpoints = CiexyzTriple::decode(src)?;
let gamma_red = src.read_u32();
let gamma_green = src.read_u32();
let gamma_blue = src.read_u32();
let intent = BitmapIntent(src.read_u32());
let profile_data = src.read_u32();
let profile_size = src.read_u32();
let _reserved = src.read_u32();
Ok(Self {
v1: header_v1,
red_mask,
green_mask,
blue_mask,
alpha_mask,
color_space: color_space_type,
endpoints,
gamma_red,
gamma_green,
gamma_blue,
intent,
profile_data,
profile_size,
})
}
}
fn validate_v1_header(header: &BitmapInfoHeader) -> Result<(), BitmapError> {
if header.width < 0 {
return Err(BitmapError::Unsupported("negative width"));
}
if header.width == 0 || header.height == 0 {
return Err(BitmapError::InvalidSize);
}
// In the modern world bitmaps with bpp < 24 are rare, and it is even more rare for the bitmaps
// which are placed on the clipboard as DIBs, therefore we could safely skip the support for
// such bitmaps.
const SUPPORTED_BIT_COUNT: &[u16] = &[24, 32];
if !SUPPORTED_BIT_COUNT.contains(&header.bit_count) {
return Err(BitmapError::Unsupported("unsupported bit count"));
}
// This is only relevant for bitmaps with bpp < 24, which are not supported.
if header.clr_used != 0 {
return Err(BitmapError::Unsupported("color table is not supported"));
}
Ok(())
}
fn validate_v5_header(header: &BitmapV5Header) -> Result<(), BitmapError> {
validate_v1_header(&header.v1)?;
// We support only uncompressed DIB bitmaps as it is the most common case for clipboard-copied bitmaps.
const DIBV5_SUPPORTED_COMPRESSION: &[BitmapCompression] = &[BitmapCompression::RGB, BitmapCompression::BITFIELDS];
if !DIBV5_SUPPORTED_COMPRESSION.contains(&header.v1.compression) {
return Err(BitmapError::Unsupported("unsupported compression"));
}
if header.v1.compression == BitmapCompression::BITFIELDS {
// Currently, we only support the standard order, BGRA, for the bitfields compression.
let is_bgr = header.red_mask == 0x00FF0000 && header.green_mask == 0x0000FF00 && header.blue_mask == 0x000000FF;
// Note: when there is no alpha channel, the mask is 0x00000000 and we support this too.
let is_supported_alpha = header.alpha_mask == 0 || header.alpha_mask == 0xFF000000;
if !is_bgr || !is_supported_alpha {
return Err(BitmapError::Unsupported(
"non-standard color masks for `BITFIELDS` compression are not supported",
));
}
}
const SUPPORTED_COLOR_SPACE: &[ColorSpace] = &[
ColorSpace::SRGB,
// Assume that Windows color space is sRGB, either way we don't have enough information on
// the clipboard to convert it to other color spaces.
ColorSpace::WINDOWS,
];
if !SUPPORTED_COLOR_SPACE.contains(&header.color_space) {
return Err(BitmapError::Unsupported("not supported color space"));
}
Ok(())
}
struct PngEncoderContext {
bitmap: Vec<u8>,
width: u16,
height: u16,
color_type: png::ColorType,
}
/// Computes the stride of an uncompressed RGB bitmap.
///
/// INVARIANT: `width <= output (stride) <= width * 4`
///
/// In an uncompressed bitmap, the stride is the number of bytes needed to go from the start of one
/// row of pixels to the start of the next row. The image format defines a minimum stride for an
/// image. In addition, the graphics hardware might require a larger stride for the surface that
/// contains the image.
///
/// For uncompressed RGB formats, the minimum stride is always the image width in bytes, rounded up
/// to the nearest DWORD (4 bytes). The following formula is used to calculate the stride:
///
/// ```
/// stride = ((((width * bit_count) + 31) & ~31) >> 3)
/// ```
///
/// From Microsoft doc: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader
fn rgb_bmp_stride(width: u16, bit_count: u16) -> usize {
debug_assert!(bit_count <= 32);
// No side effects, because u16::MAX * 32 + 31 < u16::MAX * u16::MAX < u32::MAX
#[expect(clippy::arithmetic_side_effects)]
{
(((usize::from(width) * usize::from(bit_count)) + 31) & !31) >> 3
}
}
fn bgra_to_top_down_rgba(
header: &BitmapInfoHeader,
src_bitmap: &[u8],
preserve_alpha: bool,
) -> Result<PngEncoderContext, BitmapError> {
// DIB may be encoded bottom-up, but the format we target, PNG, is top-down.
let should_flip_vertically = header.is_bottom_up();
let width = header.width();
let height = header.height();
let src_n_samples = usize::from(header.bit_count / 8);
let src_stride = rgb_bmp_stride(width, header.bit_count);
let (dst_color_type, dst_n_samples) = if preserve_alpha {
(png::ColorType::Rgba, 4)
} else {
(png::ColorType::Rgb, 3)
};
// Per invariants: height * width * dst_n_samples <= 10_000 * 10_000 * 4 < u32::MAX
#[expect(clippy::arithmetic_side_effects)]
let dst_bitmap_len = usize::from(height) * usize::from(width) * dst_n_samples;
// Prevent allocation of huge buffers.
ensure(dst_bitmap_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?;
let mut rows_normal;
let mut rows_reversed;
let rows: &mut dyn Iterator<Item = &[u8]> = if should_flip_vertically {
rows_reversed = src_bitmap.chunks_exact(src_stride).rev();
&mut rows_reversed
} else {
rows_normal = src_bitmap.chunks_exact(src_stride);
&mut rows_normal
};
// DIB stores BGRA colors while PNG uses RGBA.
// DIBv1 (CF_DIB) does not have alpha channel, and the fourth byte is always set to 0xFF.
// DIBv5 (CF_DIBV5) supports alpha channel, so we should preserve it if it is present.
let transform: fn((&mut [u8], &[u8])) = match (header.bit_count, dst_color_type) {
(24 | 32, png::ColorType::Rgb) => |(pixel_out, pixel_in)| {
pixel_out[0] = pixel_in[2];
pixel_out[1] = pixel_in[1];
pixel_out[2] = pixel_in[0];
},
(24, png::ColorType::Rgba) => |(pixel_out, pixel_in)| {
pixel_out[0] = pixel_in[2];
pixel_out[1] = pixel_in[1];
pixel_out[2] = pixel_in[0];
pixel_out[3] = 0xFF;
},
(32, png::ColorType::Rgba) => |(pixel_out, pixel_in)| {
pixel_out[0] = pixel_in[2];
pixel_out[1] = pixel_in[1];
pixel_out[2] = pixel_in[0];
pixel_out[3] = pixel_in[3];
},
_ => unreachable!("possible values are restricted by header validation and logic above"),
};
// Per invariants: width * dst_n_samples <= 10_000 * 4 < u32::MAX
#[expect(clippy::arithmetic_side_effects)]
let dst_stride = usize::from(width) * dst_n_samples;
let mut dst_bitmap = vec![0u8; dst_bitmap_len];
dst_bitmap
.chunks_exact_mut(dst_stride)
.zip(rows)
.for_each(|(dst_row, src_row)| {
let dst_pixels = dst_row.chunks_exact_mut(dst_n_samples);
let src_pixels = src_row.chunks_exact(src_n_samples);
dst_pixels.zip(src_pixels).for_each(transform);
});
Ok(PngEncoderContext {
bitmap: dst_bitmap,
width,
height,
color_type: dst_color_type,
})
}
fn encode_png(ctx: &PngEncoderContext) -> Result<Vec<u8>, BitmapError> {
let mut output: Vec<u8> = Vec::new();
let width = u32::from(ctx.width);
let height = u32::from(ctx.height);
let mut encoder = png::Encoder::new(&mut output, width, height);
encoder.set_color(ctx.color_type);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&ctx.bitmap)?;
writer.finish()?;
Ok(output)
}
/// Converts `CF_DIB` to PNG.
pub fn dib_to_png(input: &[u8]) -> Result<Vec<u8>, BitmapError> {
let mut src = ReadCursor::new(input);
let header = BitmapInfoHeader::decode(&mut src).map_err(BitmapError::Decode)?;
validate_v1_header(&header)?;
// We support only uncompressed DIB bitmaps as it is the most common case for clipboard-copied bitmaps.
// However, for DIBv1 specifically, BitmapCompression::BITFIELDS is not supported even when the order is BGRA,
// because there is an additional variable-sized header holding the color masks that we dont support yet.
const DIBV1_SUPPORTED_COMPRESSION: &[BitmapCompression] = &[BitmapCompression::RGB];
if !DIBV1_SUPPORTED_COMPRESSION.contains(&header.compression) {
return Err(BitmapError::Unsupported("unsupported compression"));
}
let png_ctx = bgra_to_top_down_rgba(&header, src.remaining(), false)?;
encode_png(&png_ctx)
}
/// Converts `CF_DIB` to PNG.
pub fn dibv5_to_png(input: &[u8]) -> Result<Vec<u8>, BitmapError> {
let mut src = ReadCursor::new(input);
let header = BitmapV5Header::decode(&mut src).map_err(BitmapError::Decode)?;
validate_v5_header(&header)?;
let png_ctx = bgra_to_top_down_rgba(&header.v1, src.remaining(), true)?;
encode_png(&png_ctx)
}
fn top_down_rgba_to_bottom_up_bgra(
info: png::OutputInfo,
src_bitmap: &[u8],
) -> Result<(BitmapInfoHeader, Vec<u8>), BitmapError> {
let no_alpha = info.color_type != png::ColorType::Rgba;
let width = u16::try_from(info.width).map_err(|_| BitmapError::WidthTooBig)?;
let height = u16::try_from(info.height).map_err(|_| BitmapError::HeightTooBig)?;
#[expect(clippy::arithmetic_side_effects)] // width * 4 <= 10_000 * 4 < u32::MAX
let stride = usize::from(width) * 4;
let src_rows = src_bitmap.chunks_exact(stride);
// As per invariants: stride * height <= width * 4 * height <= 10_000 * 4 * 10_000 <= u32::MAX.
#[expect(clippy::arithmetic_side_effects)]
let dst_len = stride * usize::from(height);
let dst_len = u32::try_from(dst_len).map_err(|_| BitmapError::InvalidSize)?;
let header = BitmapInfoHeader {
width: i32::from(width),
height: i32::from(height),
bit_count: 32, // 4 samples * 8 bits
compression: BitmapCompression::RGB,
size_image: dst_len,
x_pels_per_meter: 0,
y_pels_per_meter: 0,
clr_used: 0,
clr_important: 0,
};
let dst_len = usize::try_from(dst_len).map_err(|_| BitmapError::InvalidSize)?;
let mut dst_bitmap = vec![0; dst_len];
// Reverse rows to draw the image from bottom to top.
let dst_rows = dst_bitmap.chunks_exact_mut(stride).rev();
let transform: fn((&mut [u8], &[u8])) = if no_alpha {
|(dst_pixel, src_pixel)| {
dst_pixel[0] = src_pixel[2];
dst_pixel[1] = src_pixel[1];
dst_pixel[2] = src_pixel[0];
dst_pixel[3] = 0xFF;
}
} else {
|(dst_pixel, src_pixel)| {
dst_pixel[0] = src_pixel[2];
dst_pixel[1] = src_pixel[1];
dst_pixel[2] = src_pixel[0];
dst_pixel[3] = src_pixel[3];
}
};
dst_rows.zip(src_rows).for_each(|(dst_row, src_row)| {
let dst_pixels = dst_row.chunks_exact_mut(4);
let src_pixels = src_row.chunks_exact(4);
dst_pixels.zip(src_pixels).for_each(transform);
});
Ok((header, dst_bitmap))
}
fn decode_png(mut input: &[u8]) -> Result<(png::OutputInfo, Vec<u8>), BitmapError> {
let mut decoder = png::Decoder::new(Cursor::new(&mut input));
// We need to produce 32-bit DIB, so we should expand the palette to 32-bit RGBA.
decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::EXPAND);
let mut reader = decoder.read_info()?;
let Some(output_buffer_len) = reader.output_buffer_size() else {
return Err(BitmapError::BufferTooBig);
};
// Prevent allocation of huge buffers.
ensure(output_buffer_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?;
let mut buffer = vec![0; output_buffer_len];
let info = reader.next_frame(&mut buffer)?;
buffer.truncate(info.buffer_size());
Ok((info, buffer))
}
/// Converts PNG to `CF_DIB` format.
pub fn png_to_cf_dib(input: &[u8]) -> Result<Vec<u8>, BitmapError> {
// FIXME(perf): its possible to allocate a single array and to directly write both the header and the actual bitmap inside.
// Currently, the code is performing three allocations: one inside `decode_png`, one inside `top_down_rgba_to_bottom_up_bgra`
// and one in the body of this function.
let (png_info, rgba_bytes) = decode_png(input)?;
let (header, bgra_bytes) = top_down_rgba_to_bottom_up_bgra(png_info, &rgba_bytes)?;
let output_len = header
.size()
.checked_add(bgra_bytes.len())
.ok_or(BitmapError::BufferTooBig)?;
ensure(output_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?;
let mut output = vec![0; output_len];
{
let mut dst = WriteCursor::new(&mut output);
header.encode(&mut dst).map_err(BitmapError::Encode)?;
dst.write_slice(&bgra_bytes);
}
Ok(output)
}
/// Converts PNG to `CF_DIBV5` format.
pub fn png_to_cf_dibv5(input: &[u8]) -> Result<Vec<u8>, BitmapError> {
// FIXME(perf): its possible to allocate a single array and to directly write both the header and the actual bitmap inside.
// Currently, the code is performing three allocations: one inside `decode_png`, one inside `top_down_rgba_to_bottom_up_bgra`
// and one in the body of this function.
let (png_info, rgba_bytes) = decode_png(input)?;
let (header_v1, bgra_bytes) = top_down_rgba_to_bottom_up_bgra(png_info, &rgba_bytes)?;
let header = BitmapV5Header {
v1: header_v1,
// Windows sets these masks for 32-bit bitmaps even if BITFIELDS compression is not used.
red_mask: 0x00FF0000,
green_mask: 0x0000FF00,
blue_mask: 0x000000FF,
alpha_mask: 0xFF000000,
color_space: ColorSpace::SRGB,
endpoints: Default::default(),
gamma_red: 0,
gamma_green: 0,
gamma_blue: 0,
intent: BitmapIntent::LCS_GM_IMAGES,
profile_data: 0,
profile_size: 0,
};
let output_len = header
.size()
.checked_add(bgra_bytes.len())
.ok_or(BitmapError::BufferTooBig)?;
ensure(output_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?;
let mut output = vec![0; output_len];
{
let mut dst = WriteCursor::new(&mut output);
header.encode(&mut dst).map_err(BitmapError::Encode)?;
dst.write_slice(&bgra_bytes);
}
Ok(output)
}
/// Use this when establishing invariants.
#[inline]
#[must_use]
fn check_invariant(condition: bool) -> Option<()> {
condition.then_some(())
}
/// Returns `None` when the condition is unmet.
#[inline]
#[must_use]
fn ensure(condition: bool) -> Option<()> {
condition.then_some(())
}

View file

@ -1,179 +0,0 @@
#[derive(Debug)]
pub enum HtmlError {
InvalidFormat,
InvalidUtf8(core::str::Utf8Error),
InvalidInteger(core::num::ParseIntError),
InvalidConversion,
}
impl core::fmt::Display for HtmlError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
HtmlError::InvalidFormat => write!(f, "invalid CF_HTML format"),
HtmlError::InvalidUtf8(_error) => write!(f, "invalid UTF-8"),
HtmlError::InvalidInteger(_error) => write!(f, "failed to parse integer"),
HtmlError::InvalidConversion => write!(f, "invalid integer conversion"),
}
}
}
impl core::error::Error for HtmlError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
HtmlError::InvalidFormat => None,
HtmlError::InvalidUtf8(utf8_error) => Some(utf8_error),
HtmlError::InvalidInteger(parse_int_error) => Some(parse_int_error),
HtmlError::InvalidConversion => None,
}
}
}
impl From<core::str::Utf8Error> for HtmlError {
fn from(error: core::str::Utf8Error) -> Self {
HtmlError::InvalidUtf8(error)
}
}
impl From<core::num::ParseIntError> for HtmlError {
fn from(error: core::num::ParseIntError) -> Self {
HtmlError::InvalidInteger(error)
}
}
/// Converts `CF_HTML` format to plain HTML text.
///
/// Note that the `CF_HTML` format is using UTF-8, and the input is expected to be valid UTF-8.
/// However, there is no easy way to know the size of the `CF_HTML` payload:
/// 1) its typically not null-terminated, and
/// 2) reading the headers is already half of the work.
///
/// Because of that, this function takes the input as a byte slice and finds the end of the payload itself.
/// This is expected to be more convenient at the callsite.
pub fn cf_html_to_plain_html(input: &[u8]) -> Result<&str, HtmlError> {
const EOL_CONTROL_CHARS: &[u8] = b"\r\n";
let mut start_fragment = None;
let mut end_fragment = None;
// Well move the lower bound of this slice until all headers are read.
let mut cursor = input;
loop {
let line = {
// We use a custom logic for splitting lines, instead of something like `str::lines`.
// Thats because `str::lines` does not split at carriage return (`\r`) not followed by line feed (`\n`).
// In `CF_HTML` format, the line ending could be represented using `\r` alone.
let eol_pos = cursor
.iter()
.position(|byte| EOL_CONTROL_CHARS.contains(byte))
.ok_or(HtmlError::InvalidFormat)?;
core::str::from_utf8(&cursor[..eol_pos])?
};
match line.split_once(':') {
Some((key, value)) => match key {
"StartFragment" => {
start_fragment = Some(header_value_to_u32(value)?);
}
"EndFragment" => {
end_fragment = Some(header_value_to_u32(value)?);
}
_ => {
// We are not interested in other headers.
}
},
None => {
// At this point, we reached the end of the headers.
if let (Some(start), Some(end)) = (start_fragment, end_fragment) {
let start = usize::try_from(start).map_err(|_| HtmlError::InvalidConversion)?;
let end = usize::try_from(end).map_err(|_| HtmlError::InvalidConversion)?;
// Ensure start and end values are properly bounded.
if !(start < end && end < input.len()) {
return Err(HtmlError::InvalidFormat);
}
// Extract the fragment from the original buffer.
let fragment = core::str::from_utf8(&input[start..end])?;
return Ok(fragment);
} else {
// If required headers were not found, the input is considered invalid.
return Err(HtmlError::InvalidFormat);
}
}
};
// Skip EOL control characters and prepare for next line.
cursor = &cursor[line.len()..];
while let Some(b'\n' | b'\r') = cursor.first() {
cursor = &cursor[1..];
}
}
fn header_value_to_u32(value: &str) -> Result<u32, core::num::ParseIntError> {
value.trim_start_matches('0').parse::<u32>()
}
}
/// Converts plain HTML text to `CF_HTML` format.
pub fn plain_html_to_cf_html(fragment: &str) -> String {
const POS_PLACEHOLDER: &str = "0000000000";
let mut buffer = String::new();
let mut write_header = |key: &str, value: &str| {
// This relation holds: key.len() + value.len() + ":\r\n".len() < usize::MAX
// Rationale: we know all possible values (see code below), and they are much smaller than `usize::MAX`.
#[expect(clippy::arithmetic_side_effects)]
let size = key.len() + value.len() + ":\r\n".len();
buffer.reserve(size);
buffer.push_str(key);
buffer.push(':');
let value_pos = buffer.len();
buffer.push_str(value);
buffer.push_str("\r\n");
value_pos
};
write_header("Version", "0.9");
let start_html_header_value_pos = write_header("StartHTML", POS_PLACEHOLDER);
let end_html_header_value_pos = write_header("EndHTML", POS_PLACEHOLDER);
let start_fragment_header_value_pos = write_header("StartFragment", POS_PLACEHOLDER);
let end_fragment_header_value_pos = write_header("EndFragment", POS_PLACEHOLDER);
let start_html_pos = buffer.len();
buffer.push_str("<html>\r\n<body>\r\n<!--StartFragment-->");
let start_fragment_pos = buffer.len();
buffer.push_str(fragment);
let end_fragment_pos = buffer.len();
buffer.push_str("<!--EndFragment-->\r\n</body>\r\n</html>");
let end_html_pos = buffer.len();
let start_html_pos_value = format!("{start_html_pos:0>10}");
let end_html_pos_value = format!("{end_html_pos:0>10}");
let start_fragment_pos_value = format!("{start_fragment_pos:0>10}");
let end_fragment_pos_value = format!("{end_fragment_pos:0>10}");
let mut replace_placeholder = |value_begin_idx: usize, header_value: &str| {
// We know that: value_begin_idx + POS_PLACEHOLDER.len() < usize::MAX
// Rationale: the headers are written at the beginning, and were not indexing outside of the string.
#[expect(clippy::arithmetic_side_effects)]
let value_end_idx = value_begin_idx + POS_PLACEHOLDER.len();
buffer.replace_range(value_begin_idx..value_end_idx, header_value);
};
replace_placeholder(start_html_header_value_pos, &start_html_pos_value);
replace_placeholder(end_html_header_value_pos, &end_html_pos_value);
replace_placeholder(start_fragment_header_value_pos, &start_fragment_pos_value);
replace_placeholder(end_fragment_header_value_pos, &end_fragment_pos_value);
buffer
}

View file

@ -1,5 +0,0 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
pub mod bitmap;
pub mod html;

View file

@ -1,62 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.4.0...ironrdp-cliprdr-native-v0.5.0)] - 2025-12-18
### <!-- 4 -->Bug Fixes
- Prevent window class registration error on multiple sessions ([#1047](https://github.com/Devolutions/IronRDP/issues/1047)) ([a2af587e60](https://github.com/Devolutions/IronRDP/commit/a2af587e60e869f0235703e21772d1fc6a7dadcd))
When starting a second clipboard session, `RegisterClassA` would fail
with `ERROR_CLASS_ALREADY_EXISTS` because window classes are global to
the process. Now checks if the class is already registered before
attempting registration, allowing multiple WinClipboard instances to
coexist.
### <!-- 7 -->Build
- Bump windows from 0.61.3 to 0.62.1 ([#1010](https://github.com/Devolutions/IronRDP/issues/1010)) ([79e71c4f90](https://github.com/Devolutions/IronRDP/commit/79e71c4f90ea68b14fe45241c1cf3953027b22a2))
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.3.0...ironrdp-cliprdr-native-v0.4.0)] - 2025-08-29
### <!-- 4 -->Bug Fixes
- Map `E_ACCESSDENIED` WinAPI error code to `ClipboardAccessDenied` error (#936) ([b0c145d0d9](https://github.com/Devolutions/IronRDP/commit/b0c145d0d9cf2f347e537c08ce9d6c35223823d5))
When the system clipboard updates, we receive an `Updated` event. Then
we try to open it, but we can get `AccessDenied` error because the
clipboard may still be locked for another window (like _Notepad_). To
handle this, we have special logic that attempts to open the clipboard
in the event of such errors.
The problem is that so far, the `ClipboardAccessDenied` error was not mapped.
## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.3...ironrdp-cliprdr-native-v0.1.4)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.2...ironrdp-cliprdr-native-v0.1.3)] - 2025-02-03
### <!-- 4 -->Bug Fixes
- Handle `WM_ACTIVATEAPP` in `clipboard_subproc` ([#657](https://github.com/Devolutions/IronRDP/issues/657)) ([9b2926ea12](https://github.com/Devolutions/IronRDP/commit/9b2926ea1212d3f9dec9354334d5bdaa1bebd81e))
Previously, the function handled only `WM_ACTIVATE`.
## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.1...ironrdp-cliprdr-native-v0.1.2)] - 2025-01-28
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo ([#631](https://github.com/Devolutions/IronRDP/issues/631)) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.0...ironrdp-cliprdr-native-v0.1.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))

View file

@ -1,35 +0,0 @@
[package]
name = "ironrdp-cliprdr-native"
version = "0.5.0"
readme = "README.md"
description = "Native CLIPRDR static channel backend implementations for IronRDP"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.5" } # public
ironrdp-core = { path = "../ironrdp-core", version = "0.1" }
tracing = { version = "0.1", features = ["log"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_DataExchange",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-APACHE

View file

@ -1 +0,0 @@
../../LICENSE-MIT

View file

@ -1,7 +0,0 @@
# IronRDP CLIPRDR native backends
Native CLIPRDR backend implementations. Currently only Windows is supported.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP

Some files were not shown because too many files have changed in this diff Show more