Drm deb in release workflow (#15776)

* docs(agents): add a comment-length rule

Comments were growing to document rejected alternatives, past bugs and
measurements. That belongs in the commit message, not the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(drm): build the unattended-wayland deb in the release workflow

The deb was built by a separate drm-capture workflow on a plain runner,
so it diverged from every other Linux deb: different base, different
vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as
build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path --
same ubuntu18.04 container, same vcpkg install, same rust and flutter.
libdrmtap is built on the runner first and handed to the container via
DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it.

The job is ungated, so the --drm packaging path is exercised on every PR;
only publishing stays gated on upload-artifact. drm-capture.yml is
deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests
that workflow ran are no longer executed by CI.

Three bugs the move exposed:

- build.py anchored the libdrmtap paths on abspath(__file__), which is
  only cwd-independent on Python >= 3.9 (bpo-20443). The packaging
  container runs 3.6 and chdir's into flutter/, so the ABI-gate
  cross-check resolved one directory off and every --drm packaging run
  would have died with FileNotFoundError. Captured as REPO_ROOT at
  import instead.
- DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt
  dir inside the repo's own third_party/libdrmtap at the pinned sha is
  the pinned object, not an override, and is now verified as such.
- The variant's Depends carried a bare libdrm2. libdrmtap needs
  drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package
  installed and could never capture.

The loader also logs the dlerror now instead of discarding it, so a
soname or glibc mismatch is named rather than surfacing as a generic
"libdrmtap not available".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors

libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes
from the ubuntu18.04 container, so the package has a mixed glibc floor and
declared neither half. It installed happily on Ubuntu 20.04 / Debian 11
(glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the
PipeWire portal -- the one thing this variant exists to avoid. Measure the
floor off the staged objects and put it in Depends, so apt refuses with a
reason instead of handing over a package that can never capture.

Measured rather than written down: the number moves whenever either base does,
and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides
whether that whole family can install.

drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the
libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted
Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined
symbol at dlopen. libdrmtap's own meson.build carries the same wrong number.

Upload the deb on always(): the run that fails the drm check is the one whose
artifact is most worth downloading. Publish stays gated on success, so an
unverified build still cannot reach a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
RustDesk
2026-08-07 08:31:09 +08:00
committed by GitHub
co-authored by Claude Opus 5
parent ddad47925c
commit 9a81c8a138
7 changed files with 364 additions and 723 deletions
-449
View File
@@ -1,449 +0,0 @@
name: DRM capture (opt-in drm feature)
# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact
# up/download used by the deb job authenticates with the runtime token, not this one. Declared at
# the workflow level so the reusable bridge workflow called below inherits the same bound.
permissions:
contents: read
# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to
# record that a given commit on master was verified.
concurrency:
group: drm-capture-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows
# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related
# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing.
#
# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is
# the drm-off configuration and that stays the primary verified one.
on:
workflow_dispatch:
pull_request:
paths:
- "libs/scrap/src/common/drm_reader.rs"
- "libs/scrap/src/common/drm_render.rs"
- "libs/scrap/src/common/drmtap_dl.rs"
- "libs/scrap/src/common/mod.rs"
- "libs/scrap/Cargo.toml"
# The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes
# what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here:
# measured over the last 100 commits, it alone would have fired this workflow 13 times and
# the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release
# build, almost always for a dependency the drm path never touches. A lockfile bump that
# does affect it arrives with a manifest or source change, which is triggered above.
- "Cargo.toml"
- "src/ipc.rs"
- "src/ipc/**"
- "src/server/drm_capturer.rs"
- "src/server/wayland.rs"
- "src/server/display_service.rs"
# These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the
# producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the
# whole drm verification.
- "src/server.rs"
- "src/server/input_service.rs"
- "src/platform/linux.rs"
- "build.py"
- ".github/workflows/drm-capture.yml"
push:
branches:
- master
# Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push
# that touches only the missing paths (a squash merge, a direct push) skips re-verification.
paths:
- "libs/scrap/src/common/drm_reader.rs"
- "libs/scrap/src/common/drm_render.rs"
- "libs/scrap/src/common/drmtap_dl.rs"
- "libs/scrap/src/common/mod.rs"
- "libs/scrap/Cargo.toml"
# The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes
# what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here:
# measured over the last 100 commits, it alone would have fired this workflow 13 times and
# the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release
# build, almost always for a dependency the drm path never touches. A lockfile bump that
# does affect it arrives with a manifest or source change, which is triggered above.
- "Cargo.toml"
- "src/ipc.rs"
- "src/ipc/**"
- "src/server/drm_capturer.rs"
- "src/server/wayland.rs"
- "src/server/display_service.rs"
# These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the
# producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the
# whole drm verification.
- "src/server.rs"
- "src/server/input_service.rs"
- "src/platform/linux.rs"
- "build.py"
- ".github/workflows/drm-capture.yml"
env:
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
FLUTTER_VERSION: "3.24.5"
jobs:
drm-tests:
name: drm unit tests (linux)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
swap-storage: false
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
persist-credentials: false
- name: Install prerequisites
shell: bash
run: |
sudo apt-get -y update
sudo apt-get install -y \
clang cmake curl gcc git g++ \
libpam0g-dev libasound2-dev libunwind-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxdo-dev libxfixes-dev nasm wget
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
shell: bash
run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name
# filter would skip the sibling asserts that also matter in this configuration, notably the one
# bounding `size_of::<Data>()`, which the new DmabufDesc variant grows.
# The two skips are the same ones the stock CI applies: both need a real display server and fail
# on a headless runner regardless of this feature.
- name: Run rustdesk crate tests with the drm feature
shell: bash
run: |
cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \
--no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state
# The capture backend itself lives in the scrap crate, so its unit tests are a separate
# package. `--lib` keeps this to unit tests; none of them touch a device or a display server.
- name: Run scrap crate tests with the drm feature
shell: bash
run: |
cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib
libdrmtap:
name: libdrmtap pin, build and .so contract
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Install libdrmtap build deps
shell: bash
run: |
sudo apt-get -y update
sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \
libegl1-mesa-dev libgles2-mesa-dev
# Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or
# moved pin fails here rather than in a release job.
- name: Fetch the pinned libdrmtap and build the .so
shell: bash
run: |
python3 - <<'PY'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("b", "build.py")
b = importlib.util.module_from_spec(spec)
sys.argv = ["build.py"]
spec.loader.exec_module(b)
so = b.build_libdrmtap_so()
print(f"::notice::built {so}")
open("so_path", "w").write(so)
PY
# The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or
# glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices,
# so assert here that the object we would ship really carries EGL and really exports every symbol
# the runtime loader resolves.
- name: Assert the .so contract (EGL enabled, loader symbols present)
shell: bash
run: |
# Strict mode is load-bearing here: without it the trailing ::notice echo would return 0
# and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing
# loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.)
set -euo pipefail
SO="$(cat so_path)"
echo "checking $SO"
missing=0
# Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot
# drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be
# silently dropped from the loop), and the count is asserted below so a refactor of the
# loader away from b"..." literals cannot quietly turn this whole check into a no-op that
# iterates zero times and passes.
# `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would
# abort the script before the explicit ::error guard below can say WHY it failed; the
# guard on nsyms is the intended reporter for that case.
syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \
| sed 's/^b"//; s/"$//' | sort -u || true)
nsyms=$(echo "$syms" | grep -c . || true)
if [ "$nsyms" -lt 13 ]; then
echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader"
missing=1
fi
# Inspect the object ONCE into a variable, then match with bash's own pattern operator --
# NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a
# FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the
# first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's
# status, so a library that HAS the symbol is reported as missing it. Measured on a real
# EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both
# missing and failed the step. Note the obvious repair does NOT work -- materializing the
# output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the
# producer, and it fails identically (measured). Today's release-sized .so happens to fit in
# the buffer, which is the only reason this has not fired yet.
exported="$(nm -D --defined-only "$SO")"
strs="$(strings "$SO")"
for sym in $syms; do
# Line-anchored: wrap in newlines so the pattern can require a whole line, the same
# thing `grep " T $sym$"` was expressing.
if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then
echo "::error::libdrmtap does not export $sym, which the runtime loader resolves"
missing=1
fi
done
# EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the
# vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look
# for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub
# build really lacks is the dlopen target name and the import call itself.
for s in "libEGL.so.1" "eglCreateImageKHR"; do
if [[ "$strs" != *"$s"* ]]; then
echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing"
missing=1
fi
done
test "$missing" -eq 0
echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)"
# The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it.
generate-bridge:
uses: ./.github/workflows/bridge.yml
drm-deb:
name: unattended-wayland deb (verification build)
needs: generate-bridge
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
swap-storage: false
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
persist-credentials: false
- name: Restore bridge files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Install prerequisites
shell: bash
run: |
sudo apt-get -y update
# Same list the stock linux job needs, plus the flutter desktop toolchain and the three
# libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages).
sudo apt-get install -y \
clang cmake curl gcc git g++ ninja-build meson pkg-config \
libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxdo-dev libxfixes-dev nasm wget \
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
- name: Install vcpkg dependencies
shell: bash
run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Setup flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Patch flutter
shell: bash
run: |
cd $(dirname $(dirname $(which flutter)))
# `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off
# the pinned value, because the failed test becomes the script's exit status. An explicit
# if/else skips instead. Reading the values from the environment rather than interpolating
# github expressions into the script also keeps this off zizmor's template-injection list.
# (spelled out in prose: a literal expression marker here, even in a comment, is parsed by
# actionlint and breaks workflow linting.)
if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then
git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff"
else
echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch"
fi
- name: Build the unattended-wayland deb
shell: bash
run: |
set -euo pipefail
# The features have to be on the cargo line HERE, because the packaging line below passes
# --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for
# the list rather than repeating it -- get_features() is the single definition of what
# these flags mean, and a hardcoded copy silently ships something other than what
# `build.py --drm` produces the moment that function changes. The flags must be the same
# on both lines for that to hold, so keep them in one variable.
DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste)
FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)"
echo "features from build.py: $FEATURES"
# Assert rather than trust: an empty or error-shaped value would otherwise become a cargo
# line that builds a stock binary, which only the staged-binary marker check would catch.
# Match whole comma-separated TOKENS, one feature at a time. A substring test would depend
# on the order get_features happens to append them (failing a correct build the day they
# are reordered) and would also match a future feature that merely contains "drm", the same
# trap build.py avoids by splitting on commas rather than testing a substring.
for want in drm drm-wake; do
case ",$FEATURES," in
*",$want,"*) ;;
*) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;;
esac
done
cargo build --locked --lib --release --features "$FEATURES"
python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo
# build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status,
# and assert the two things that make it the drm variant at all.
- name: Assert the deb is a real drm build
shell: bash
run: |
# Strict mode so the mid-script checks can fail the step (without it only the LAST
# command's status counts and the greps above it are decorative).
set -euo pipefail
# Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches
# before its own `test -n` could report, and on several matches produced a multi-line
# value whose `mv` failed with something unrelated to the real problem.
shopt -s nullglob
debs=(rustdesk-unattended-wayland-*.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}"
exit 1
fi
deb="${debs[0]}"
echo "::notice::built $deb ($(stat -c %s "$deb") bytes)"
# Pipe-free for the same reason as the .so contract step above (see the comment there:
# a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under
# pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the
# shape is the hazard and the next `-q` added here would inherit it silently.
contents="$(dpkg -c "$deb")"
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y"
exit 1
fi
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink"
exit 1
fi
# The library alone does not make this a drm build: build.py stages it whenever --drm is
# passed, independently of what was compiled, and the deb name is what tells a user this
# is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that
# only exists when the feature is compiled in -- otherwise a stock binary could ship
# under the unattended-wayland name with a library it can never reach.
rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then
echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build"
exit 1
fi
mv "$deb" "${deb%.deb}-x86_64.deb"
# MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the
# ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least
# as new as the runner -- and that number belongs in the artifact NAME, because a comment in this
# file is not visible to whoever downloads it from the Actions UI.
- name: Measure the deb glibc floor
id: floor
shell: bash
run: |
# Strict mode for the same reason as the assert step above. The floor extraction gets an
# explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a
# bare pipeline failure.
set -euo pipefail
# Same nullglob array + count assertion as the assert step above, for the same two
# reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and
# several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This
# was the sibling left behind when that one was fixed.)
shopt -s nullglob
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}"
exit 1
fi
deb="${debs[0]}"
rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor
floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \
| grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)"
test -n "$floor"
echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT"
echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)"
# Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays
# out of the published release either way; the name states the floor so nobody installs it on an
# older distro and hits a bare loader error.
- name: Upload the deb
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb
path: rustdesk-unattended-wayland-*-x86_64.deb
+270
View File
@@ -1749,6 +1749,276 @@ jobs:
files: |
res/rustdesk-${{ env.VERSION }}*.zst
# Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same
# rust and flutter -- only with the drm feature on, so it ships as the separate
# rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too
# old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and
# flatpak need that job, and a failure here must not skip them.
build-rustdesk-linux-drm:
needs: [generate-bridge]
name: build rustdesk linux drm x86_64
runs-on: ubuntu-22.04
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Maximize build space
run: |
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo apt-get update -y
sudo apt-get install -y nasm
sudo apt-get install -y qemu-user-static
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Set Swap Space
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
- name: Free Space
run: |
df -h
free -m
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu
components: "rustfmt"
- name: Save Rust toolchain version
run: |
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
- name: Disable rust bridge build
run: |
# only build cdylib
sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml
- name: Restore bridge files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
run: |
sudo apt install -y libva-dev && apt show libva-dev
if ! $VCPKG_ROOT/vcpkg \
install \
--triplet x64-linux \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true
shell: bash
# The container's meson is too old to build libdrmtap, so build it here from the pin in
# build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR.
- name: Build libdrmtap
run: |
sudo apt-get install -y meson ninja-build pkg-config \
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
python3 - <<'PY'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("b", "build.py")
b = importlib.util.module_from_spec(spec)
sys.argv = ["build.py"]
spec.loader.exec_module(b)
print(f"::notice::built {b.build_libdrmtap_so()}")
PY
shell: bash
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk
id: vcpkg
with:
arch: x86_64
distro: ubuntu18.04
githubToken: ${{ github.token }}
setup: |
ls -l "${PWD}"
ls -l /opt/artifacts/vcpkg/installed
dockerRunArgs: |
--volume "${PWD}:/workspace"
--volume "/opt/artifacts:/opt/artifacts"
shell: /bin/bash
install: |
apt-get update -y
echo -e "installing deps"
apt-get install -y \
build-essential \
clang \
cmake \
curl \
gcc \
git \
g++ \
libayatana-appindicator3-dev \
libasound2-dev \
libclang-10-dev \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxdo-dev \
libxfixes-dev \
llvm-10-dev \
nasm \
ninja-build \
pkg-config \
tree \
python3 \
rpm \
unzip \
wget \
xz-utils \
libssl-dev
# we have libopus compiled by us.
apt-get remove -y libopus-dev || true
# output devs
ls -l ./
tree -L 3 /opt/artifacts/vcpkg/installed
run: |
# disable git safe.directory
git config --global --add safe.directory "*"
# rust
pushd /opt
# do not use rustup, because memory overflow in qemu
wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz
tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz
cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh
rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu
# edit config
mkdir -p ~/.cargo/
echo """
[source.crates-io]
registry = 'https://github.com/rust-lang/crates.io-index'
""" > ~/.cargo/config
cat ~/.cargo/config
# start build
pushd /workspace
export VCPKG_ROOT=/opt/artifacts/vcpkg
# use the .so built on the runner; build.py checks it is the pinned checkout
export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg
# ask build.py for the features so this line and the packaging line cannot drift
FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features)
# an empty or error-shaped value would silently build a stock binary
for want in drm drm-wake; do
case ",$FEATURES," in
*",$want,"*) ;;
*) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;;
esac
done
cargo build --locked --lib --features "$FEATURES" --release
rm -rf target/release/deps target/release/build
rm -rf ~/.cargo
# Setup Flutter
# disable git safe.directory
git config --global --add safe.directory "*"
export PATH=/opt/flutter/bin:$PATH
pushd /opt
wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
flutter doctor -v
if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then
pushd /opt/flutter
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
popd
fi
# build flutter
pushd /workspace
export CARGO_INCREMENTAL=0
export DEB_ARCH=amd64
python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo
for name in rustdesk*??.deb; do
mv "$name" "${name%%.deb}-x86_64.deb"
done
# build.py can exit 0 on some inner failures, so check the artifact rather than the status.
# The package name is the informed consent for consent-free capture, so a stock binary must
# never ship under it: assert the bundled library AND the dlopen path in the binary.
- name: Check the deb is a drm build
run: |
set -euo pipefail
# Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a
# hardcoded name fails with a bare exit 1 the first time those two drift.
shopt -s nullglob
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}"
exit 1
fi
deb="${debs[0]}"
echo "DRM_DEB=$deb" >> "$GITHUB_ENV"
contents="$(dpkg -c "$deb")"
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
echo "::error::$deb has no versioned libdrmtap.so.0.x.y"
exit 1
fi
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
echo "::error::$deb has no libdrmtap.so.0 soname symlink"
exit 1
fi
rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then
echo "::error::$deb was not built with the drm feature"
exit 1
fi
shell: bash
- name: Publish debian package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
files: |
${{ env.DRM_DEB }}
# No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built.
# always(), because a deb that failed the check above is the one most worth downloading.
- name: Upload deb
if: always() && env.DRM_DEB != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.DRM_DEB }}
path: ${{ env.DRM_DEB }}
build-rustdesk-linux-sciter:
if: ${{ inputs.upload-artifact }}
runs-on: ${{ matrix.job.on }}
+8
View File
@@ -61,6 +61,14 @@
* Do not make formatting-only changes.
* Keep naming/style consistent with nearby code.
### Comments
* Keep them short: one line by default, three at most.
* Say **why**, never what. If the code already says it, delete the comment.
* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR.
* A comment must never be longer than the code it describes.
* Applies to YAML, shell and Python too, not just Rust.
### Be minimally invasive
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
+71 -15
View File
@@ -15,6 +15,11 @@ import argparse
import sys
from pathlib import Path
# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__
# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04
# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code.
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
windows = platform.platform().startswith('Windows')
osx = platform.platform().startswith(
'Darwin') or platform.platform().startswith("macOS")
@@ -327,8 +332,8 @@ def get_features(args):
# straight from `target/release` without bundling libdrmtap, without the rename, without
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
# injection. The separate package name is the informed consent this feature rests on (see
# docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it.
# injection. The separate package name is the informed consent this feature rests on, so
# refuse rather than ship a stock-named build of it.
branch = linux_packaging_branch()
if branch != 'deb':
raise Exception(
@@ -399,20 +404,40 @@ LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
# that has meson to a packaging container that does not.
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
try:
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
except ValueError:
return False
if not inside or not os.path.isdir(os.path.join(src, '.git')):
return False
try:
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
except (subprocess.SubprocessError, OSError):
return False
return head == LIBDRMTAP_SHA
def _validate_libdrmtap_pin():
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
# environment (or a malformed sha) must not be able to fail a build that never touches
# libdrmtap.
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
# which tests it for truthiness.
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
prebuilt = None
overridden = [
name
for name, value, pinned in (
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
# `or None` so an empty value reads as unset here exactly as it does in
# build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=`
# would demand the opt-in for an override that is not going to happen.
('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None),
('DRMTAP_PREBUILT_DIR', prebuilt, None),
)
if value != pinned
]
@@ -452,7 +477,6 @@ def build_libdrmtap_so():
# library target is built (the source also carries a helper binary we do not
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
_validate_libdrmtap_pin()
repo_root = os.path.dirname(os.path.abspath(__file__))
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
@@ -474,7 +498,7 @@ def build_libdrmtap_so():
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
# is immune to a ref being moved or repointed.
src = os.path.join(repo_root, 'third_party', 'libdrmtap')
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
if not os.path.exists(os.path.join(src, 'meson.build')):
if os.path.isdir(src):
shutil.rmtree(src)
@@ -526,7 +550,7 @@ def _assert_so_has_egl(so_path):
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
# lacks. Same two markers the drm-capture workflow asserts in CI.
# lacks.
try:
with open(so_path, 'rb') as f:
blob = f.read()
@@ -566,11 +590,8 @@ def assert_so_satisfies_the_runtime_abi_gate(so_path):
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
return
so_ver = tuple(int(g) for g in m.groups())
# Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already
# chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError
# and fails every --drm packaging run. (It did; CI caught it.)
gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
with open(gate_path) as f:
gate_src = f.read()
@@ -613,6 +634,37 @@ def stage_libdrmtap_into_deb(so_path):
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
def _max_glibc_minor(path):
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
# librustdesk.so is ~45 MB.
best = 0
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return best
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
best = max(best, int(m.group(1)))
tail = chunk[-16:]
def measured_glibc_floor():
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
# object is higher -- and it moves whenever either base does.
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
if os.path.isfile(p) and not os.path.islink(p)]
minor = max((_max_glibc_minor(p) for p in paths), default=0)
if not minor:
raise Exception(
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
'is what lets it install on a host where libdrmtap can never load')
return f'2.{minor}'
def retarget_control_to_drm_variant():
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
@@ -620,6 +672,8 @@ def retarget_control_to_drm_variant():
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
# own runtime deps, which the stock package has no reason to carry.
path = '../res/DEBIAN/control'
floor = measured_glibc_floor()
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
with open(path) as f:
lines = f.readlines()
out = []
@@ -628,7 +682,9 @@ def retarget_control_to_drm_variant():
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
elif line.startswith('Depends:'):
out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n')
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
f'libc6 (>= {floor})\n')
else:
out.append(line)
body = ''.join(out)
-255
View File
@@ -1,255 +0,0 @@
# DRM/KMS capture — security model & threat model
The optional `drm` feature adds a Linux capture backend that reads the active
scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent
dialog**. It exists for unattended / login-screen / Wayland scenarios where the
portal prompt is not acceptable. Because it bypasses consent, treat it as a
**privileged, opt-in host-mode feature**, not a normal Wayland capture backend.
## How it works
Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients'
framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so
the `drm` feature does the read **in-process in that root service**: it
`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no
`setcap` helper. On the **default (split) path** the root service does not touch
pixels: it exports the active scanout as a DMA-BUF and passes just that
**read-only** fd to the unprivileged user `--server` over a dedicated
service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an
**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is
imported once and re-imports are elided), detiles/converts it to linear RGBA in
its own unprivileged address space, and feeds the encoder — so **on that path**
the root service never copies scanout pixels and never loads libEGL/libGLESv2
(measured on the running service, see *Auditing*). Only the **CPU fallback path**
(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer
has no render node of its own, see *When the CPU fallback is chosen* below)
copies the scanout to packed BGRA inside the root service and streams those bytes
over `_drm`.
**The no-GL property is a property of the default path, not of the process.** Be
precise about it, because the CPU fallback is the whole reason the split exists:
converting a scanout in-process means decoding whatever layout it is in, and a
tiled scanout (the common case on modern Intel and AMD) can only be decoded
through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process
step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the
scanout needs a GPU detile. So a host that has fallen back to the CPU path can
map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about
that is bound the cases: the fallback is entered only for the three reasons
listed below, never as a silent degradation of the split path (the loader refuses
a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot
turn into "convert in the privileged process"), and a linear or CPU-mappable
scanout is converted without touching GL. Every host measured here runs the split
path with zero GL regions in the service; a CPU-fallback host is a different
posture and is worth measuring separately. This mirrors the Windows
`portable_service` split (a privileged process captures, an unprivileged one
presents) but reuses RustDesk's own hardened IPC.
- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the
library or one of its runtime deps is missing the load fails cleanly and the
caller falls back to the PipeWire/portal path.
- The loader also **refuses a library that cannot do the split** — and, more
broadly, any version outside the vetted window. Accepted is exactly the pinned
minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is
refused (`0.4.x` included, even though it carries the split entry points, because
it decodes a padded scanout pitch at the wrong stride), and a **newer minor is
refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only
field-by-field verified against the pinned minor; widening the window is a
deliberate act done together with re-verifying the layouts and moving the
build pin. Independently of the version report, a library that does not
actually export
`drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or
pre-release build) is refused as well. The only way to capture with such a library is the
in-process convert, which in the root service means loading the vendor GL stack
there, so it is refused and the caller falls back to PipeWire/portal. The
privileged process therefore never loads GL because of which file happened to
be on the load path; the CPU fallback below is entered only for a fact about
the seat or the consumer.
- The reader restricts the device it opens to a realpath under `/dev/dri/`
(`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode
(`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or
installed by this package**: there is no `setcap`, no capability-bearing file,
and no capture group in this deployment. Being precise about what that does
and does not guarantee: an empty `helper_path` is not by itself a "helper
disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six
hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the
directory this package installs into, and `fork`/`exec`s the first executable
it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is
unreachable for two independent reasons: the root service holds
`CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the
shared library, so no helper exists at any of those paths. They are all
root-writable-only, so a helper appearing there would not be an escalation
either, but the honest statement is "a privileged child is spawned only if a
helper binary exists at one of those fixed root-owned paths, and this package
never installs one", not "never".
- The `_drm` socket lives beside the hardened `_service` socket
(`/tmp/<app>-service/ipc_drm`). It is `0666` so the unprivileged `--server`
can connect, but every accepted peer is authorized in `handle_drm_conn`
(`authorize_service_scoped_ipc_connection`: peer must be root or the active
session uid, with a `/proc/<pid>/exe` identity match). Connectable is not
authorized.
## Threat model
- **Consent bypass.** This mode does not show the portal "select what to share"
prompt. On a misconfigured install it could expose the login screen, the lock
screen, or another local user's graphical session.
- **The scanout parse runs in the root service.** Moving the read in-process
removes the old `setcap` helper and its world-exec attack surface. On the
**default (split) path** the root service does only a **metadata-only** parse
of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer
detile / pixel-format conversion runs in the **unprivileged `--server`**,
outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only —
geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4`
(`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side,
because the format check is delegated to the unprivileged converter, which
handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30,
HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the
DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and
`drm_reader` `dup()`s it — which shares the same open file description and so
preserves that access mode — so the unprivileged consumer can map the scanout
for reading but never write into the live framebuffer. On the **CPU fallback
path** the pixel-format conversion / detile instead runs inside the
`CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has
format / stride / geometry and integer-overflow guards (`drm_reader.rs`
`grab`), and non-32bpp scanouts are rejected before the copy. The device is
realpath-gated to `/dev/dri/` on both paths.
- **`_drm` is a screen-content channel.** It is authorized per connection (see
above); without that authz any local process could read the screen. Authorization
is also **re-checked on every frame**, not only at accept, because DRM/KMS
capture is not session-scoped: it grabs the physical scanout of a CRTC no matter
which session owns the display. So when the active session changes -- a user
logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than
continued (`drm: _drm peer no longer matches the active session`; observed with
peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes
with it). That is what stops an outgoing greeter process from capturing the
logged-in user's screen. The cost is a reconnect, not the session: the client
re-establishes itself against the new session's `--server` on its own in about
2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the
**default (split) path** the channel carries the scanout DMA-BUF fd, passed to
the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor
(the `--server` holds an import-once EGLImage cache, so a given scanout buffer
is imported once and re-imports are elided); the peer can map the scanout for
reading but cannot write it. The **CPU fallback path** instead carries plain
packed-BGRA bytes over the same authorized socket (no fd passing, no shared
memory).
- **When the CPU fallback is chosen.** The split path is the default; the
consumer asks the service for the CPU-converted frame in two cases: no render
node can be opened for this seat, or a previous convert on this display
already failed. A third case is a **multi-GPU safety fallback**: if
the service could not name the render node of the GPU that exports the scanout
(an older `libdrmtap` without `drmtap_render_node`) and the host has more than
one render node, the consumer refuses to guess one, because importing a scanout
on a device that did not export it can succeed and return corrupted pixels
rather than fail. The conversion then happens in the service, on the device it
already has open, so it is correct by construction. Hosts with a single render
node have nothing to pick wrong and keep the DMA-BUF fast path.
- **The display wake injects synthetic input from the root service.** It is
compiled in only with the `drm-wake` feature, which `build.py --drm` adds on
top of `drm`, and it can be switched off at runtime with
`enable-drm-display-wake=N`. Building with `--features drm` alone leaves no
wake code in the binary at all, so an operator auditing the deb can answer
"is the injection path even present here?" from the artifact. A
compositor that idles long enough DISABLES a connector, leaving no scanout for
any backend, so on a `_drm` handshake that finds a CONNECTED display with no
CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to
make the compositor re-enable it. The virtual device **declares** two relative
axes and `BTN_LEFT`, because libinput classifies a device before it will treat
its events as pointer activity at all and a single axis with no buttons is
ignored outright (measured three ways on the same idle machine). What it
actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no
button press, no key events. This is deliberate input injection by privileged
code, so its bounds are worth stating precisely:
- it can only be reached through an **already-authorized** `_drm` connection
(same per-connection authz as every other use of the channel), so it grants
nothing to a local attacker that the channel itself does not;
- it runs in the root service because that is the only place it can:
`/dev/uinput` is root-only here, and a modeset of our own is not an option
since the compositor holds DRM master (the sysfs `dpms` attribute is
read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by
uid, refuse root, and are desktop-specific;
- the trigger is narrow — a connected-but-undriven connector, not "no
frames" — and connectors a wake demonstrably cannot bring back are
remembered by connector identity and stop triggering. That memory is
per-connector rather than global, so a permanently dark connector cannot
suppress the wake for a different panel, and it drops any entry later seen
scanning out. Note what that recovery rule does and does not give you: it
clears the moment the display is driven **by anything**, but nothing else
retries, so a connector latched after a wake that failed for a transient
reason stays latched until that display comes back some other way — on an
unattended host, typically not until the service restarts. It is a
deliberate trade against waking on every connection forever for a display
that is never coming;
- it is rate limited to **one wake per 20 s process-wide** with exactly one
concurrent winner (compare-exchange claim), so a reconnect storm cannot
become an input-injection storm. That bounds the injection RATE. It does
not bound how long a screen stays lit, and neither does the one-shot
property below: 20 s is shorter than every idle period measured below, so a
remote peer that reconnects in a loop can have the panel relit after each
idle-off. What that peer gains is a lit panel on a machine whose screen it
is already authorized to watch: it is visible to someone standing there,
not additional access;
- the wake is **one-shot: it resets the compositor's idle timer, it does not
hold the display on**. If nothing else keeps the session awake, the connector
idles off again one full idle period later -- measured 2026-07-31: 30.3 s at
a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a
screen lit for the length of a session is the job of RustDesk's existing
keep-awake inhibitor, not of this wake, which only recovers a connector that
is *already* dark;
- the uinput device is created and destroyed around the emit — nothing
persists in the input stack between wakes;
- without `/dev/uinput` the wake is skipped and latched off. Such a session
was already view-only (input injection on Wayland needs uinput too), so
this adds no new failure mode.
## Deployment
- **Off by default.** The `drm` feature is **not** in the default feature set and
is **not** enabled in standard release packages; the drm-off build is
byte-identical to upstream. Build it explicitly with
`python3 build.py --flutter --drm` (Linux only).
- **Separate opt-in package.** A `--drm` build ships as a distinctly named
`rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` --
`Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the
consent-free variant, so it belongs in an audit of this metadata), so
enabling consent-free capture is an explicit install choice.
- **Bundled library, no capabilities.** The package installs the versioned
`libdrmtap.so.0.<minor>.<patch>` plus a `libdrmtap.so.0` soname symlink under
`/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path
(`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not**
register the directory with the dynamic linker: no
`/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a
private library cannot shadow a system one for unrelated binaries
(Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a
development build reached through `LD_LIBRARY_PATH`.
There is no `setcap`, no `rustdesk-capture` group, and no privileged binary:
the capture runs inside the root `--service`, which already holds the
capability it needs. Hosts without `/dev/dri` access (or where the library
fails to load) transparently fall back to the PipeWire/portal path.
- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which
landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it
straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack
(2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as
"any 18.04". That is an API statement, not a binary-compatibility one:
the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the
shipped binaries carry that build host's glibc floor. Running on an older distribution means
building the deb there (or in a matching container), which the libdrm floor above permits.
Capture also requires an active KMS scanout (a Wayland/KMS session with a display
on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA
X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal.
- **Recommended for** single-user, physically-controlled, or unattended hosts.
## Auditing
```bash
# the bundled capture library and its soname symlink — no capabilities are set on either
ls -l /usr/lib/rustdesk/libdrmtap.so.0*
# the dlopen names the symlink by absolute path, so what matters is where the symlink points:
readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package
# and there should be no other object left beside it (a leftover is not loaded on its own, but it
# is what a stray ldconfig over this directory would repoint the symlink to):
ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object
ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped)
# confirm no privileged helper is present (there should be none)
getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output
```
+1 -1
View File
@@ -1,7 +1,7 @@
// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout
// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd
// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to
// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md.
// its own CPU-mapped grab (`drmtap_grab_mapped`).
use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib};
use super::Pixfmt;
+14 -3
View File
@@ -196,9 +196,20 @@ impl DrmtapLib {
std::iter::once(INSTALLED).chain(DEV_ONLY).collect()
};
unsafe {
let (lib, name) = candidates
.iter()
.find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?;
let mut errs = Vec::new();
let found = candidates.iter().find_map(|n| match Library::new(*n) {
Ok(l) => Some((l, *n)),
Err(e) => {
errs.push(format!("{n}: {e}"));
None
}
});
let Some((lib, name)) = found else {
// The dlerror names the real cause (a missing soname, a glibc too old for the
// bundled build); the caller only reports that DRM capture is off.
log::warn!("libdrmtap dlopen failed: {}", errs.join("; "));
return None;
};
// Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare
// soname, while `canonicalize` resolves a relative name against it.
let real = std::path::Path::new(name)