Skip to content

feat(linux): DRM/KMS direct capture for Wayland — no portal consent required#15420

Open
fxd0h wants to merge 23 commits into
rustdesk:masterfrom
fxd0h:feature/drm-capture-clean
Open

feat(linux): DRM/KMS direct capture for Wayland — no portal consent required#15420
fxd0h wants to merge 23 commits into
rustdesk:masterfrom
fxd0h:feature/drm-capture-clean

Conversation

@fxd0h

@fxd0h fxd0h commented Jun 25, 2026

Copy link
Copy Markdown

Problem

On Linux/Wayland, RustDesk captures the screen via xdg-desktop-portal (PipeWire), which requires an interactive consent dialog on every connection — making unattended access impossible and preventing capture of the login screen (GDM/SDDM).

Discussion: #15417 (maintainer responded YES 2026-06-25)

Solution

This PR adds an optional, off-by-default drm feature that captures the screen by reading the kernel's DRM/KMS scanout directly via libdrmtap.

  • No consent dialog — zero user interaction
  • Works at the login screen (GDM/SDDM/LightDM)
  • No compositor dependency — works before any user session starts
  • Captures the hardware cursor from the DRM cursor plane

Architecture (reworked per review — no privileged helper)

Reading another client's scanout needs CAP_SYS_ADMIN. RustDesk's root --service already holds it, so the capture runs in-process in that root service — there is no separate privileged helper binary, no setcap, and no capability-bearing file. libdrmtap is loaded with dlopen at runtime (no link-time dependency; runs on Ubuntu 18.04+), and frames are streamed to the unprivileged user --server over a dedicated, authorized service-scoped IPC channel (_drm).

--service (root, already CAP_SYS_ADMIN)                          --server (unprivileged)
  dlopen libdrmtap.so ─ read DRM/KMS scanout ─► _drm IPC (authorized) ─► encode ─► client

This mirrors the Windows portable_service split (a privileged process captures, an unprivileged one presents) and reuses RustDesk's own hardened, session-authorized IPC. The _drm peer is authorized in handle_drm_conn (must be root or the active session uid, with a /proc/<pid>/exe identity match).

Priority/fallback: DRM → PipeWire → X11. DRM is attempted only on Wayland (!is_x11()). If libdrmtap is absent, there is no active CRTC, or a grab fails, it falls back to PipeWire per-display (with a logged reason); a per-display demotion is recoverable after a cooldown.

Off by default / opt-in: the drm feature is not in the default set. When it is off the build is byte-identical to upstream (everything is #[cfg(feature = "drm")]-gated or lives only in the separate deb). Enabling it is an explicit choice: it ships as a distinctly named rustdesk-unattended-wayland package (Conflicts/Replaces rustdesk), whose name is the informed consent.

No opaque external privileged binary: the .so is built in CI from the RustDesk-controlled fork rustdesk-org/libdrmtap (cloned at main), so the privileged capture code is reviewed in that repo rather than pulled as an opaque crate.

Key files

File Change
libs/scrap/src/common/drmtap_dl.rs dlopen loader for libdrmtap.so (ABI-major check, clean fallback if absent)
libs/scrap/src/common/drm_reader.rs Direct in-process scanout read (bounded, format-validated, EGL CCS detile)
src/server/drm_capturer.rs IpcDrmCapturer + per-display availability/demotion; consumes frames over _drm
src/ipc.rs Service-scoped, authorized _drm channel + producer worker in the root --service
src/server/{wayland,display_service,input_service}.rs DRM-path integration (all #[cfg(feature = "drm")])
src/platform/linux.rs DRM cursor export + producer start (gated)
build.py Clone libdrmtap from rustdesk-org/libdrmtap main, build the .so, bundle it only in the --drm deb, register it with ldconfig (no setcap)
.github/workflows/flutter-build.yml Build a separate rustdesk-unattended-wayland deb
DRM_CAPTURE_SECURITY.md Threat model + hardening notes for the in-process model

How to build (with DRM)

python3 ./build.py --flutter --drm   # Linux only; produces the rustdesk-unattended-wayland deb

The deb registers libdrmtap.so with ldconfig so the in-process dlopen resolves. No setcap, no privileged helper, no capture group.

Tested on

GPU / Driver Environment Result
Intel Meteor Lake (i915), dual 4K GNOME Wayland ✅ Working (EGL CCS detile, in-process)
NVIDIA Jetson Orin Nano (nvidia-drm) Wayland ✅ Working
virtio-gpu (QEMU/KVM) GNOME Wayland ✅ Working (linear FB)
AMD amdgpu (RX Vega 64) ✅ libdrmtap capture verified

Login-screen (GDM) capture confirmed on Intel/i915. Clean-install validated (purge + fresh deb): postinst wires ld.so.conf.d + ldconfig, dlopen resolves, capture initializes.

Known limitations

  • Requires an active CRTC — if DPMS blanks the monitor there is no scanout to capture; it falls back to PipeWire.
  • X11 — not applicable (DRM path skipped on X11; existing behaviour unchanged).

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Linux builds add DRM/KMS capture support, helper packaging, cursor handling, Wayland integration, DRM availability reporting, localized status text, and a --uinput-service CLI path.

Changes

DRM capture support

Layer / File(s) Summary
Feature flags and helper packaging
Cargo.toml, libs/scrap/Cargo.toml, build.rs, libs/scrap/build.rs, .github/workflows/flutter-build.yml, build.py, res/DEBIAN/postinst
drm feature flags, libdrmtap-sys, helper build output, Linux build packages, packaging install steps, and setcap/patchelf commands are added for drmtap-helper.
DRM backend in scrap
libs/scrap/src/common/drm.rs, libs/scrap/src/common/mod.rs, libs/scrap/src/common/linux.rs
libs/scrap adds DRM display and capturer types, cursor caching, frame and cursor capture through libdrmtap, module re-exports, and Linux dispatch to the new variants.
Linux cursor and Wayland runtime integration
src/platform/linux.rs, src/server/display_service.rs, src/server/wayland.rs
Linux cursor lookup adds DRM cursor IDs, cursor downscaling, and /dev/dri/card* availability checks; display-service propagates the scale, and Wayland init skips fill_displays() when DRM capture is active.
Availability API and desktop tip
src/flutter_ffi.rs, flutter/lib/desktop/pages/desktop_home_page.dart, src/lang/en.rs
main_is_drm_capture_available() is exported to Flutter, the Linux home-page card switches between drm_capture_tip and wayland_experiment_tip, and the English strings add the new DRM tip text.
Localized DRM tip strings
src/lang/*
The locale maps add drm_capture_tip entries across the non-English translation files.

uinput service CLI

Layer / File(s) Summary
Dedicated uinput service mode
src/core_main.rs
core_main() dispatches --uinput-service to three Linux service threads and exits immediately on non-Linux targets.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • fufesou

Poem

A rabbit hopped through DRM moonbeams bright,
with cursors downscaled just right.
New helper paws danced in the dew,
and tip strings bloomed in every hue.
🐇✨ Capture paths now hum at night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: an optional Linux Wayland DRM/KMS capture path that bypasses portal consent.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/flutter-build.yml (1)

1539-1561: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

CI still isn't building the DRM variant.

These jobs now install the DRM/helper build dependencies, but the later Linux cargo build invocations still omit --features drm. That means the new root-crate #[cfg(feature = "drm")] code and the helper-copy path in build.rs are not exercised here, so this workflow can still publish artifacts without actually validating the feature this PR is wiring up.

Suggested follow-up outside this hunk
-cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
+cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste,drm --release
-cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
+cargo build --locked --features inline${{ matrix.job.extra_features }},drm --release --bins --jobs 1

Also applies to: 1825-1827

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/flutter-build.yml around lines 1539 - 1561, The Linux
workflow is installing DRM-related dependencies but still not validating the DRM
feature because the later cargo build steps do not pass the drm feature flag.
Update the affected cargo build invocations in flutter-build.yml to include
--features drm so the root-crate #[cfg(feature = "drm")] paths and the build.rs
helper-copy flow are exercised during CI, including the related build steps
referenced by the review.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@build.py`:
- Line 355: The Flutter DEB packaging path is missing the privileged
drmtap-helper install, so update the build logic around the packaging steps in
build.py to create the tmpdeb/usr/lib/rustdesk location and copy drmtap-helper
there just like the non-Flutter path. Also ensure the postinst flow that applies
setcap to drmtap-helper continues to match the installed location so
Flutter-built DEBs include the helper correctly.
- Around line 394-396: The drmtap-helper packaging step in build.py is copying
from the wrong working directory and hiding failures with “|| true”, which can
produce a package without the helper. Update the helper copy logic near the
staged payload setup to source drmtap-helper from the build output/package
payload instead of the current flutter directory, and remove the failure masking
so DRM builds fail if the helper is missing. Use the existing packaging flow
around system2 and the tmpdeb/usr/lib/rustdesk staging path to locate and fix
this.

In `@build.rs`:
- Around line 90-102: In build.rs, the DEP_DRMTAP_HELPER_BIN handling currently
ignores the missing-helper case by only acting when the env var is present,
which can produce packages without drmtap-helper. Update the logic around the
existing if let Ok(src) block to fail fast when drm is enabled and the helper
path is absent, and keep the copy path via std::fs::copy and the drmtap-helper
target placement unchanged for the success case. Use the existing build script
symbols like DEP_DRMTAP_HELPER_BIN, OUT_DIR, target_dir, and drmtap-helper to
locate the fix.

In `@Cargo.toml`:
- Line 50: The scrap dependency is enabling the drm feature unconditionally,
which bypasses the root crate’s top-level drm flag and can desynchronize backend
selection from the #[cfg(feature = "drm")] paths. Update the Cargo.toml
dependency declaration for scrap so drm is only enabled when the crate’s own drm
feature is active, and keep the scrap feature set aligned with the build.rs
staging and Wayland/DRM selection logic.

In `@flutter/lib/desktop/pages/desktop_home_page.dart`:
- Around line 540-543: The DRM card in desktop_home_page.dart is using
bind.mainIsDrmCaptureAvailable() as if it meant the DRM backend is actually
active, but that symbol only indicates availability. Update the buildInstallCard
logic around drmAvailable/tipKey so it either checks a real “active DRM backend”
signal from the existing DRM capture flow or changes the UI copy from
“Status”/drm_capture_tip to wording that only claims availability; keep the fix
localized to the LinuxCards.add path and the surrounding tip selection.
- Around line 542-545: The shared buildInstallCard call in
desktop_home_page.dart is dropping the Wayland remediation/help link on the
non-DRM warning path, so preserve the old link when drmAvailable is false. Split
the logic around the existing buildInstallCard invocation inside the
LinuxCards.add block so the warning case keeps passing the Wayland
docs/remediation target, while the normal status path can remain unchanged. Use
the drmAvailable conditional and the buildInstallCard helper as the main anchors
when updating the card arguments.

In `@libs/scrap/src/common/drm.rs`:
- Around line 327-333: The early-return optimization in drm.rs is skipping
cursor polling in the capture path, so Wayland/DRM hardware cursor updates can
be missed when frame.fb_id stays unchanged. Update the framebuffer-unchanged
branch in the DRM capture flow to still run the cursor refresh logic before
returning, and make the same change anywhere the same skip pattern appears in
the related capture path so cursor state is always polled even when the
framebuffer is reused.

In `@src/core_main.rs`:
- Around line 401-406: The service startup in core_main currently launches async
uinput tasks with std::thread::spawn, which only creates Futures and drops them
without running them. Update the startup flow around
service::start_service_control, service::start_service_keyboard, and
service::start_service_mouse to execute on the async runtime by using
tokio::spawn or awaiting them directly from an async context, and remove the
idle thread sleep loop if it is only keeping the process alive.

In `@src/lang/eo.rs`:
- Line 401: The Esperanto text for drm_capture_tip uses the wrong word, since
sennombre means “countless” instead of “unattended.” Update the string in the eo
translation entry to use the correct Esperanto wording for “unattended” while
keeping the rest of the message intact, and verify the phrasing still reads
naturally in the translation map around drm_capture_tip.

In `@src/lang/gu.rs`:
- Line 401: The `drm_capture_tip` translation in `gu.rs` contains mixed
Hindi/Gujarati text in the final clause; replace that clause with a fully
Gujarati sentence that keeps the same meaning for unattended use and the login
screen. Update the string in the Gujarati locale entry so it reads naturally in
Gujarati while preserving the DRM/KMS capture and portal-permission context.

In `@src/platform/linux.rs`:
- Around line 2288-2306: The current is_drm_capture_available() check only looks
for /dev/dri/card* nodes, which can report DRM as available even when the helper
is missing or unusable. Update this probe to use the actual DRM helper/display
capability check already used by capture startup, or cache the last successful
DRM initialization result, and keep the existing Wayland/X11 gating in place so
the Flutter UI reflects real DRM availability.
- Around line 439-463: The DRM cursor path in linux.rs currently returns cursor
pixel data without verifying that it matches the requested cursor id, unlike the
XFixes path. In the get_cursor_data logic around scrap::drm_cursor(), compare
the fetched cursor’s id against the input cursor serial/hcursor and only build
and return CursorData when they match; otherwise skip returning data so callers
don’t receive pixels for a different cursor state.

In `@src/server/display_service.rs`:
- Around line 329-332: The cursor downscale is being stored globally in
`set_cursor_downscale`, which lets each display overwrite
`CURSOR_DOWNSCALE_BITS` and breaks mixed-DPI multi-monitor behavior. Update the
display-specific path in `DisplayService` to keep cursor scale tied to the
current display or cursor position instead of a single process-wide value, and
make the cursor rendering/hotspot lookup use that per-display scale when
applying the XFixes/XWayland downscale.

In `@src/server/wayland.rs`:
- Around line 173-184: PipeWire fallback is being marked initialized too early
in wayland::fill_displays logic, so if DRM/KMS enumeration succeeds but
Capturer::new(display) later fails, future calls are blocked by the
PIPEWIRE_INITIALIZED guard without any capturers. Update the flow around
fill_displays, try_fix_logical_size, and the DRM path so PIPEWIRE_INITIALIZED is
only set after DRM capturers are successfully created, or add a retry path that
falls back to Wayland/PipeWire displays when DRM capturer creation fails.

---

Outside diff comments:
In @.github/workflows/flutter-build.yml:
- Around line 1539-1561: The Linux workflow is installing DRM-related
dependencies but still not validating the DRM feature because the later cargo
build steps do not pass the drm feature flag. Update the affected cargo build
invocations in flutter-build.yml to include --features drm so the root-crate
#[cfg(feature = "drm")] paths and the build.rs helper-copy flow are exercised
during CI, including the related build steps referenced by the review.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e754eb7b-ff6d-4227-8c30-32b51fb91890

📥 Commits

Reviewing files that changed from the base of the PR and between ff226f6 and cccb7ee.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (66)
  • .github/workflows/flutter-build.yml
  • Cargo.toml
  • build.py
  • build.rs
  • flutter/lib/desktop/pages/desktop_home_page.dart
  • libs/scrap/Cargo.toml
  • libs/scrap/build.rs
  • libs/scrap/src/common/drm.rs
  • libs/scrap/src/common/linux.rs
  • libs/scrap/src/common/mod.rs
  • res/DEBIAN/postinst
  • src/core_main.rs
  • src/flutter_ffi.rs
  • src/lang/ar.rs
  • src/lang/be.rs
  • src/lang/bg.rs
  • src/lang/ca.rs
  • src/lang/cn.rs
  • src/lang/cs.rs
  • src/lang/da.rs
  • src/lang/de.rs
  • src/lang/el.rs
  • src/lang/en.rs
  • src/lang/eo.rs
  • src/lang/es.rs
  • src/lang/et.rs
  • src/lang/eu.rs
  • src/lang/fa.rs
  • src/lang/fi.rs
  • src/lang/fr.rs
  • src/lang/ge.rs
  • src/lang/gu.rs
  • src/lang/he.rs
  • src/lang/hi.rs
  • src/lang/hr.rs
  • src/lang/hu.rs
  • src/lang/id.rs
  • src/lang/it.rs
  • src/lang/ja.rs
  • src/lang/ko.rs
  • src/lang/kz.rs
  • src/lang/lt.rs
  • src/lang/lv.rs
  • src/lang/ml.rs
  • src/lang/nb.rs
  • src/lang/nl.rs
  • src/lang/pl.rs
  • src/lang/pt_PT.rs
  • src/lang/ptbr.rs
  • src/lang/ro.rs
  • src/lang/ru.rs
  • src/lang/sc.rs
  • src/lang/sk.rs
  • src/lang/sl.rs
  • src/lang/sq.rs
  • src/lang/sr.rs
  • src/lang/sv.rs
  • src/lang/ta.rs
  • src/lang/th.rs
  • src/lang/tr.rs
  • src/lang/tw.rs
  • src/lang/uk.rs
  • src/lang/vi.rs
  • src/platform/linux.rs
  • src/server/display_service.rs
  • src/server/wayland.rs
💤 Files with no reviewable changes (1)
  • libs/scrap/build.rs

Comment thread build.py Outdated
Comment thread build.py Outdated
Comment thread build.rs Outdated
Comment thread Cargo.toml Outdated
Comment thread flutter/lib/desktop/pages/desktop_home_page.dart Outdated
Comment thread src/lang/gu.rs Outdated
Comment thread src/platform/linux.rs Outdated
Comment thread src/platform/linux.rs Outdated
Comment thread src/server/display_service.rs Outdated
Comment thread src/server/wayland.rs Outdated
@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Pushed a fixup commit (066a0e3) addressing the valid findings:

Fixed:

  • Cargo.toml: removed hardcoded drm from the scrap dependency features — it was enabling the DRM backend unconditionally for all Linux builds, bypassing the top-level drm feature flag
  • build.rs: changed if let Ok(src) to .expect() so a missing DEP_DRMTAP_HELPER_BIN with drm enabled is a build error, not a silent omission
  • build.py: added drmtap-helper install to build_flutter_deb; fixed wrong working-directory path in build_deb_from_folder (was cp drmtap-helper ... from flutter/, should be cp ../target/release/drmtap-helper ...)
  • drm.rs: extracted cursor capture into update_cursor() method driven by a separate cursor_tick counter that increments on every grab call — cursor now updates even when the scanout framebuffer is unchanged
  • linux.rs get_cursor_data: added cursor id guard (c.id != hcursor → bail!) to match the XFixes path behaviour
  • en.rs: softened drm_capture_tip from "is active" to "is available" since the check is a pre-flight probe (card node + Wayland), not confirmation that capture succeeded
  • eo.rs: fixed Esperanto "sennombre" (countless) → "senatende" (unattended)
  • gu.rs: replaced mixed Gujarati/Devanagari translation with pure Gujarati

Pushed back on:

src/core_main.rs:406 ("critical" — async futures not awaited): start_service_control, start_service_keyboard, and start_service_mouse all carry #[tokio::main(flavor = "current_thread")], which the proc-macro expands into a synchronous blocking function. std::thread::spawn(|| service::start_service_control()) is identical to the pattern already used by start_uinput_service() in src/platform/linux.rs:530-541 (upstream code). No fix needed.

src/server/display_service.rs:332 (global cursor downscale): The single-global pattern is intentional for the common single-monitor case; per-display tracking is a separate improvement and out of scope for this PR.

src/server/wayland.rs:184 (PIPEWIRE_INITIALIZED set too early): Valid concern but the current behaviour is: if DRM display enumeration succeeds, we skip PipeWire entirely. A Capturer::new failure on a DRM display falls back with an error log, not a silent hang. Full PipeWire retry on DRM capturer failure would add significant complexity; flagging for a follow-up.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in Linux drm feature to support direct DRM/KMS scanout capture on Wayland (bypassing xdg-desktop-portal consent dialogs) and wires it through server, platform, packaging, CI, and Flutter UI so unattended/login-screen capture becomes possible.

Changes:

  • Introduces a new DRM/KMS capturer backend in libs/scrap and updates Linux display/capture selection to prefer DRM on Wayland with graceful fallback behavior.
  • Updates Linux server/platform paths for cursor handling and DRM availability signaling (including Flutter FFI + UI messaging).
  • Extends build/packaging/CI to ship the privileged helper and required Linux dependencies, and adds a new localized UI tip string across languages.

Reviewed changes

Copilot reviewed 66 out of 67 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/server/wayland.rs Skips PipeWire display fill when DRM displays are detected to avoid portal session requirements.
src/server/display_service.rs Adds Linux cursor downscale propagation based on display scale.
src/platform/linux.rs Adds DRM cursor support, cursor downscaling logic, and a DRM availability heuristic for UI/feature gating.
src/flutter_ffi.rs Exposes DRM availability to Flutter via FFI on Linux.
flutter/lib/desktop/pages/desktop_home_page.dart Adjusts Wayland warning/status card based on DRM availability and adds a close button.
src/core_main.rs Adds --uinput-service mode for a minimal root helper providing only uinput IPC services.
libs/scrap/src/common/mod.rs Gates and re-exports the DRM backend and DRM cursor access behind the drm feature.
libs/scrap/src/common/linux.rs Adds DRM-first capture selection on Wayland (DRM → PipeWire → X11) when drm is enabled.
libs/scrap/src/common/drm.rs New DRM/KMS capture backend using libdrmtap, including hardware cursor plane capture.
libs/scrap/Cargo.toml Adds drm feature and optional libdrmtap-sys dependency for Linux.
libs/scrap/build.rs Removes a large commented-out ffmpeg helper section (no functional build logic added here in the diff shown).
Cargo.toml Adds workspace-level drm feature and optional direct dependency on libdrmtap-sys to surface helper path via links.
Cargo.lock Locks libdrmtap-sys and updates transitive dependency versions accordingly.
build.rs Copies drmtap-helper into the target output when DRM feature is enabled on Linux.
build.py Installs drmtap-helper into the Debian package staging area and adjusts rpath via patchelf.
res/DEBIAN/postinst Sets cap_sys_admin+ep on /usr/lib/rustdesk/drmtap-helper during package configure.
.github/workflows/flutter-build.yml Adds Linux build dependencies needed by the DRM helper/backend and packaging steps (libdrm/libcap/libseccomp/patchelf).
src/lang/en.rs Adds drm_capture_tip and adjusts wording of wayland_experiment_tip.
src/lang/ar.rs Adds drm_capture_tip translation.
src/lang/be.rs Adds drm_capture_tip translation.
src/lang/bg.rs Adds drm_capture_tip translation.
src/lang/ca.rs Adds drm_capture_tip translation.
src/lang/cn.rs Adds drm_capture_tip translation.
src/lang/cs.rs Adds drm_capture_tip translation.
src/lang/da.rs Adds drm_capture_tip translation.
src/lang/de.rs Adds drm_capture_tip translation.
src/lang/el.rs Adds drm_capture_tip translation.
src/lang/eo.rs Adds drm_capture_tip translation.
src/lang/es.rs Adds drm_capture_tip translation.
src/lang/et.rs Adds drm_capture_tip translation.
src/lang/eu.rs Adds drm_capture_tip translation.
src/lang/fa.rs Adds drm_capture_tip translation.
src/lang/fi.rs Adds drm_capture_tip translation.
src/lang/fr.rs Adds drm_capture_tip translation.
src/lang/ge.rs Adds drm_capture_tip translation.
src/lang/gu.rs Adds drm_capture_tip translation.
src/lang/he.rs Adds drm_capture_tip translation.
src/lang/hi.rs Adds drm_capture_tip translation.
src/lang/hr.rs Adds drm_capture_tip translation.
src/lang/hu.rs Adds drm_capture_tip translation.
src/lang/id.rs Adds drm_capture_tip translation.
src/lang/it.rs Adds drm_capture_tip translation.
src/lang/ja.rs Adds drm_capture_tip translation.
src/lang/ko.rs Adds drm_capture_tip translation.
src/lang/kz.rs Adds drm_capture_tip translation.
src/lang/lt.rs Adds drm_capture_tip translation.
src/lang/lv.rs Adds drm_capture_tip translation.
src/lang/ml.rs Adds drm_capture_tip translation.
src/lang/nb.rs Adds drm_capture_tip translation.
src/lang/nl.rs Adds drm_capture_tip translation.
src/lang/pl.rs Adds drm_capture_tip translation.
src/lang/pt_PT.rs Adds drm_capture_tip translation.
src/lang/ptbr.rs Adds drm_capture_tip translation.
src/lang/ro.rs Adds drm_capture_tip translation.
src/lang/ru.rs Adds drm_capture_tip translation.
src/lang/sc.rs Adds drm_capture_tip translation.
src/lang/sk.rs Adds drm_capture_tip translation.
src/lang/sl.rs Adds drm_capture_tip translation.
src/lang/sq.rs Adds drm_capture_tip translation.
src/lang/sr.rs Adds drm_capture_tip translation.
src/lang/sv.rs Adds drm_capture_tip translation.
src/lang/ta.rs Adds drm_capture_tip translation.
src/lang/th.rs Adds drm_capture_tip translation.
src/lang/tr.rs Adds drm_capture_tip translation.
src/lang/tw.rs Adds drm_capture_tip translation.
src/lang/uk.rs Adds drm_capture_tip translation.
src/lang/vi.rs Adds drm_capture_tip translation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/server/display_service.rs
Comment thread src/platform/linux.rs Outdated
Comment thread src/platform/linux.rs Outdated
Comment thread src/platform/linux.rs Outdated
@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

Pushed 8f83e6c addressing the Copilot findings:

  • display_service.rs:333set_cursor_downscale now called once, from all.first(), before the display map loop. Eliminates the "last display wins" ordering dependency on mixed-DPI setups.
  • linux.rs:444 — Removed the bail! on cursor id mismatch in the DRM get_cursor_data path. The DRM cursor is polled asynchronously so the id can legitimately advance between get_cursor() and get_cursor_data(); returning the latest snapshot avoids the 10-second MouseCursorService error backoff.
  • linux.rs:435 — Clamped nhx/nhy to [0, new_dim-1] after rounding so the hotspot never goes out of bounds.
  • linux.rs:2305is_drm_capture_available now scans /dev/dri/ for any card* entry via read_dir instead of probing only card0..card3.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/server/display_service.rs (1)

313-320: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Global cursor downscale is still incorrect for mixed-DPI multi-monitor capture.

This change removes list-order dependence, but a single global factor from the primary display still mis-scales cursor size/hotspot when the active/captured display uses a different scale. Please bind cursor downscale to the current capture display (or cursor display), not process-global primary scale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/display_service.rs` around lines 313 - 320, The current cursor
downscale logic in display_service::DisplayService still uses the primary
display’s scale, which can mis-size the cursor in mixed-DPI setups. Update the
code path around the use_logical_scale handling to derive the downscale from the
active capture display or cursor display instead of all.first(), and ensure
crate::platform::linux::set_cursor_downscale is called with that per-display
scale. Keep the fix localized to the cursor-scale selection logic so the global
state no longer depends on the primary monitor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/server/display_service.rs`:
- Around line 313-320: The current cursor downscale logic in
display_service::DisplayService still uses the primary display’s scale, which
can mis-size the cursor in mixed-DPI setups. Update the code path around the
use_logical_scale handling to derive the downscale from the active capture
display or cursor display instead of all.first(), and ensure
crate::platform::linux::set_cursor_downscale is called with that per-display
scale. Keep the fix localized to the cursor-scale selection logic so the global
state no longer depends on the primary monitor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7955a23b-0f53-48dc-b323-2cac94ac5623

📥 Commits

Reviewing files that changed from the base of the PR and between cc76de1 and 8f83e6c.

📒 Files selected for processing (2)
  • src/platform/linux.rs
  • src/server/display_service.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/platform/linux.rs

@rustdesk

Copy link
Copy Markdown
Owner

What is the minimum Ubuntu version required for DRM/KMS support (We can install and start)?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 66 out of 67 changed files in this pull request and generated 8 comments.

Comment thread build.rs Outdated
Comment thread src/server/display_service.rs Outdated
Comment thread src/platform/linux.rs Outdated
Comment thread libs/scrap/src/common/mod.rs Outdated
Comment thread libs/scrap/src/common/drm.rs Outdated
Comment thread libs/scrap/src/common/drm.rs Outdated
Comment thread libs/scrap/src/common/drm.rs Outdated
Comment thread src/core_main.rs Outdated
@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

Pushed another review pass (commits 021a6c0, dadb74c) addressing deeper issues found in a full self-review of the diff:

Correctness / robustness

  • drm.rs out-of-bounds: drmtap_list_displays returns the total connected-connector count, which the C API documents may exceed max_count (only cap entries are written). The code iterated 0..n over a fixed 8-element buffer, so a machine with 9+ connected connectors would index past the end — a panic in the capture path. Now clamped to the buffer length, and max_count is derived from the Vec length so they can't drift.
  • wayland.rs capture-disable: PIPEWIRE_INITIALIZED was set to true before the fallible Capturer::new() loop. A capturer failure left the duplicate-init guard permanently returning early with zero capturers (no retry until restart). Moved the flag set to after all capturers are created and inserted, so a failure leaves a retryable state.

Build / CI

  • Removed two patchelf --set-rpath lines from build.py — they were unrelated to the DRM helper packaging and, because system2() exits on any non-zero status, would have hard-failed the sciter deb build (whose CI container never installs patchelf). Also dropped the now-unneeded patchelf apt dependency.

i18n

  • The drm_capture_tip banner is shown when DRM capture is available (pre-flight passes), not when a session is active. Corrected the first clause from "active" to "available" semantics across all 49 non-English locales, each checked by an independent back-translation. Also added the key to template.rs.

Known limitation noted in the PR body: helper packaging (setcap) is currently wired into the .deb paths only; DRM degrades gracefully to PipeWire on RPM/Arch until those are added (the feature is opt-in and off by default).

Verified: cargo check clean with both --features inline,drm and --features inline; live DRM capture + EGL CCS detiling produces a correct 3840×2160 frame on Intel i915, and the hardware cursor refreshes across unchanged-framebuffer ticks.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 68 changed files in this pull request and generated 6 comments.

Comment thread src/server/display_service.rs Outdated
Comment thread libs/scrap/src/common/drm.rs Outdated
Comment thread libs/scrap/src/common/drm.rs Outdated
Comment thread build.rs Outdated
Comment thread res/DEBIAN/postinst Outdated
Comment thread src/platform/linux.rs
@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thanks @copilot — pushed b199614 addressing the latest round:

Fixed

  • drm.rs CString panic (×2): DRM_DEVICE is user-controlled, so CString::new().unwrap() could panic on an interior NUL. Now a malformed value is treated as unset (and_then(... .ok())) in both Display::all() and Capturer::new().
  • drm_cursor() clone hotspot: added a cheap drm_cursor_id() and switched get_cursor() (polled ~33ms, only needs the id) to it, so the pixel buffer is no longer cloned on the fast path. get_cursor_data() still clones the full struct since it needs the pixels.
  • display_service.rs primary: cursor downscale now uses get_primary_2(all) (the is_primary() display), not all.first(), since the primary isn't guaranteed to be index 0 on the PipeWire/Wayland path.
  • mod.rs comment: corrected — the cursor plane is read back by the drm feature; it's just not embedded in the framebuffer.
  • build.rs comment + path: fixed the misleading "found beside the binary at runtime" comment (libdrmtap searches fixed system paths; the copy is for the packaging scripts). Also switched the profile-dir lookup from ancestors().nth(3) to "parent of the build component" for robustness.
  • core_main.rs --uinput-service: now checks is_root() and exits with a clear error instead of parking forever with services that silently failed to open /dev/uinput.

Two notes

  • build.rs cross-build depth: the nth(3) was actually invariant to --target (the triple is prepended above <profile>, so OUT_DIR's tail is always <profile>/build/<pkg>/out), but I made it structure-based regardless since that's strictly more robust.
  • is_drm_capture_available() false positives (linux.rs): this is an intentional lightweight pre-flight (Wayland + /dev/dri/card* + helper on a real search path). A full Display::all() probe would spawn the privileged helper on every home-page render; the banner is informational and capture falls back to PipeWire if DRM can't actually start, so I kept it cheap rather than authoritative.

Both feature configs (--features inline,drm and --features inline) compile clean, and live DRM capture on Intel i915 still produces a correct 3840×2160 frame.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 68 changed files in this pull request and generated 5 comments.

Comment thread src/server/display_service.rs Outdated
Comment on lines +317 to +322
#[cfg(target_os = "linux")]
if use_logical_scale {
if let Some(primary) = all.get(get_primary_2(all)) {
crate::platform::linux::set_cursor_downscale(primary.scale());
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid edge case. The cursor downscale is currently coupled to use_logical_scale (multi-monitor only). Decoupling it to fire on any single Wayland HiDPI session risks regressing the multi-monitor path that has been tested, and I don't have a single-HiDPI rig to confirm the DRM cursor actually renders oversized there. Leaving this open pending verification on real single-HiDPI hardware rather than changing the gate blind.

Comment thread src/core_main.rs Outdated
Comment thread res/DEBIAN/postinst Outdated
Comment thread build.py Outdated
Comment thread build.py Outdated
@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

thanks for the yes on #15417, heres the implementation.

quick summary, its an opt in drm feature that reads the wayland scanout directly with libdrmtap so theres no xdg-desktop-portal consent popup anymore. so it works unattended and also at the login screen (gdm/sddm) wich the portal path cant do. its off by default so normal builds dont change at all.

priority is drm then pipewire then x11, drm only gets tried on wayland and it falls back by itself if anything fails. only the small helper binary (drmtap-helper) gets cap_sys_admin, the main rustdesk process stays unprivileged.

tested it on intel meteor lake (i915, dual 4k, egl ccs detiling), nvidia jetson orin nano and virtio-gpu on qemu. amd should work in theory but i couldnt test it, dont have the hardware here. both the drm build and the normal default one compile fine.

i already went through all the coderabbit and copilot comments and resolved almost all of them. theres one i left open about the cursor downscale on a single hidpi monitor, i didnt want to touch that one blind because it could break the multi monitor path that is actually tested and i dont have a single hidpi screen here to check it properly.

couple of things id like your opinion on. the pr also adds a small --uinput-service mode and the helper packaging is only wired for deb right now, if you rather i can pull those out and keep this just about the capture. and i left drm opt in / off by default but happy to gate it however you want.

anyway im open to rework whatever, just let me know how youd want it to land. thanks

@fxd0h

fxd0h commented Jun 25, 2026

Copy link
Copy Markdown
Author

good question. i didnt do a full matrix on old ubuntus so let me tell you whats actually tested and whats the technical floor.

tested on a recent ubuntu here (kernel 7.x), the jetson orin nano and virtio under qemu, all working.

for the minimum the thing that matters is the kernel having the GETFB2 ioctl (the modifier aware framebuffer query), thats what libdrmtap uses to read the scanout buffer with its tiling modifier on real gpus like intel/amd. that one landed around kernel 4.20 so any current lts is fine, 20.04 (5.4), 22.04 (5.15), 24.04 (6.8) all have it. plain linear framebuffers like virtio would probably work even older but the tiled intel/amd path needs GETFB2.

apart from that its just userspace thats everywhere, libdrm, libcap for the setcap on the helper, libseccomp for the seccomp filter, and mesa/egl for the detiling on intel. build side needs libdrm-dev libcap-dev and libseccomp-dev wich i already put in the CI.

so short answer 20.04 LTS and up should be safe, 18.04 is marginal since its default 4.15 kernel is older than GETFB2. if you want i can spin a specific lts in a vm and actually confirm it instead of estimating, just say the word.

@fxd0h

fxd0h commented Jun 26, 2026

Copy link
Copy Markdown
Author

heads up on the security model for the privileged helper, and a pointer to the shipping discussion (#15417) where i'd like a maintainer call before this lands.

reading the active scanout needs CAP_SYS_ADMIN: the kernel only discloses framebuffer handles from drmModeGetFB2() to the drm master or to CAP_SYS_ADMIN, and theres no narrower cap for this op. its broad, i know. the design keeps it out of the main process and confined to a tiny helper.

architecture

  • two processes. main rustdesk stays unprivileged. a separate binary, drmtap-helper, carries CAP_SYS_ADMIN and does only the privileged read. they talk over an anonymous socketpair created by the library (fork/exec, helper socket on fd 3, refuses to run if fd 3 isnt a socket so it cant be run standalone).
  • tiny protocol. 3 commands (grab, get-cursor, quit) in an 8-byte struct. only attacker-controllable field is a u32 crtc_id, used as an equality filter, bad value returns "no active framebuffer" not memory unsafety.
  • one-directional fd passing. SCM_RIGHTS goes helper to main only (the dma-buf export). the unprivileged side cant pass a path or fd over the wire to make the privileged side open something.

helper confinement (libdrmtap-sys 0.4.3, which this PR now pins)

  • restricts the device it opens to a path that canonicalizes under /dev/dri/ (argv and DRM_DEVICE are attacker-influenceable, so it realpath()s and rejects anything outside /dev/dri/).
  • sets PR_SET_NO_NEW_PRIVS.
  • drops every capability except CAP_SYS_ADMIN (via libcap).
  • installs a default-KILL seccomp allowlist.
  • hard-fails: if it cant drop caps or install seccomp, it refuses to serve instead of running unconfined.

honest caveats, dont read this as more hardened than it is

  • the helper holds CAP_SYS_ADMIN for its entire lifetime. every grab re-needs drmModeGetFB2, so the cap is reduced to exactly one and kept for the whole session, not dropped after init.
  • the binary is dynamically linked (libdrm/libcap/libseccomp/EGL), not static.
  • the seccomp allowlist has no per-argument filtering (ioctl on any fd, open any path). it blocks whole syscall classes, doesnt narrow the allowed ones.
  • no SO_PEERCRED check, isolation rests on the private inherited socketpair (no third party can connect).

shipping (current PR behavior): opt-in and OFF by default (drm isnt in the default feature set, a stock build ships nothing privileged). deb-only: when drm is enabled, build.py installs the helper to /usr/lib/rustdesk/drmtap-helper and postinst setcaps it, guarded and non-fatal with a fall-back-to-pipewire warning.

the shipping/trust-model decision (bundle vs separate package vs admin-installed) is the part that needs your call, so i broke it out into #15417 rather than baking it into review here.

full security writeup is in libdrmtap's SECURITY.md, which i rewrote to match the actual code (the older version overstated the hardening).

@rustdesk

Copy link
Copy Markdown
Owner

🔴 Security — the critical surface is outside this diff

  • The TCB is the external libdrmtap-sys crate, not this PR. Everything that matters for safety — how drmtap-helper authenticates its caller, its seccomp filter, how it bounds what scanout it returns — is C
    code in the crate, invoked opaquely via drmtap_open/drmtap_grab_mapped. This PR cannot be security-signed-off without auditing libdrmtap-sys 0.4.3. Lock-pinned by checksum (good), real registry crate, but
    I couldn't verify its provenance/maturity (crates.io 403s automated requests) — the maintainers should.
  • setcap cap_sys_admin+ep on a world-executable helper = multi-user exposure. postinst grants the capability as a file capability on /usr/lib/rustdesk/drmtap-helper (mode 0755). That means any local user
    can exec it and read the DRM scanout — login screen, the lock screen, other users' sessions. On a shared/multi-user host this is a local information-disclosure primitive that didn't exist before. And
    cap_sys_admin is famously broad ("the new root"): any exploitable bug in the helper becomes a serious LPE, mitigated only by its (external, unaudited-here) seccomp filter. Worth: documenting the multi-user
    caveat, and confirming the helper drops/scopes privilege to the invoking session.
  • Blast-radius question — do official builds enable drm? CI (flutter-build.yml) adds libcap-dev/libdrm-dev/libseccomp-dev, but I don't see --features drm added to any build command, and build.py only ships
    the helper if [ -f target/release/drmtap-helper ]. So: if release .debs enable drm, every RustDesk Linux install ships the world-exec cap_sys_admin helper; if not, exposure is limited to self-builders.
    This needs to be explicit and intentional — please confirm and document.
  • --uinput-service (new root mode) exposes the keyboard/mouse/control uinput IPC as a standalone root daemon. The injection sockets must be access-controlled (any local process that can reach them can
    synthesize input → LPE). It reuses the existing uinput::service machinery (so the ACL is pre-existing), but it's newly exposed, and nothing in the diff launches it — how is it started, and with what socket
    permissions?

🟡 Supply chain & build

  • Cargo.toml uses libdrmtap-sys = "0.4.3" (caret → ^0.4.3). Cargo.lock pins exact + checksum, so committed-lock builds are reproducible, but for a crate that compiles a privileged binary I'd pin exactly
    (=0.4.3) to prevent a silent 0.4.x bump on lock regeneration.
  • build.rs/libs/scrap/build.rs rely on DEP_DRMTAP_HELPER_BIN via the links="drmtap" mechanism and panic! if absent — fine, but it hard-fails the whole build if the crate's contract changes; acceptable for
    an opt-in feature.

🟡 Correctness / quality (minor)

  • Capturer { ctx: *mut drmtap_ctx } — a raw pointer makes it !Send/!Sync. Confirm the capture loop never moves it across threads (or that an explicit unsafe impl Send is justified); the X11/Wayland
    variants are the comparison point.
  • Blocking thread::sleep inside frame() (16 ms rate-limit + up-to-33 ms skip-sleep) paces the capture thread by sleeping rather than returning promptly with WouldBlock. Likely fine, but verify it doesn't
    add latency to display-switch/resolution-change handling or starve the server's frame loop.
  • Single global CURSOR_DOWNSCALE_BITS applies the primary display's scale to the cursor on all displays — on mixed-DPI multi-monitor the cursor size will be off on the non-primary output. Edge case,
    acknowledged in a comment, but not fully solved.
  • get_cursor_data ignores the requested hcursor on the DRM path (returns the latest snapshot) — documented tradeoff to avoid the 10 s backoff; reasonable.
  • Silent DRM→PipeWire fallback swallows the Err from drm::Display::primary()/all() with no log of why DRM was skipped (beyond the helper-missing UI check). A log::debug! on the fallback reason would help
    field diagnosis.

@rustdesk

Copy link
Copy Markdown
Owner

• The DRM feature has packaging paths that can either ship a privileged helper unintentionally or miss the helper for folder-based DEBs, and there are correctness issues around HiDPI cursor scaling and
primary monitor selection. These should be fixed before considering the patch correct.

Full review comments:

  • [P1] Gate drmtap-helper packaging on the current feature set — /Users/hzhou/rustdesk/build.py:359-359
    When a normal DEB is built after a previous --features drm build without cleaning target/release, this file-existence check still copies the stale drmtap-helper, and postinst will grant it
    cap_sys_admin+ep. That breaks the opt-in guarantee for default/non-DRM packages; gate the copy on the current features value rather than on whether an old artifact happens to exist.

  • [P2] Copy the folder build's helper from the staged payload — /Users/hzhou/rustdesk/build.py:401-401
    In build_deb_from_folder, the package payload comes from binary_folder, but this line ignores that staged payload and instead pulls whatever happens to be in ../target/release. Packaging a prebuilt or
    cross-arch DRM bundle with no matching local target tree will omit /usr/lib/rustdesk/drmtap-helper or can pick a stale/wrong-arch helper, so postinst cannot enable DRM capture for that DEB.

  • [P2] Set cursor downscale for single HiDPI Wayland displays — /Users/hzhou/rustdesk/src/server/display_service.rs:319-321
    This only updates the downscale factor when Wayland reports more than one display. On a single Wayland/DRM HiDPI monitor with scale > 1, cursor_downscale() remains 1.0, so the new cursor downscale path
    is never applied and the remote cursor is rendered too large; update/reset the cursor downscale independently of the multi-monitor logical-scale switch.

  • [P2] Preserve the compositor primary output in DRM display list — /Users/hzhou/rustdesk/libs/scrap/src/common/drm.rs:226-226
    DRM connector order is not necessarily the compositor's primary output. In a multi-monitor Wayland session where the primary monitor is not the first DRM connector, this marks the wrong display as
    primary, so get_primary_2() chooses the wrong initial display and the cursor downscale can be derived from the wrong monitor; use the matched Wayland primary/name when available.

@rustdesk

Copy link
Copy Markdown
Owner

Security Audit — libdrmtap-sys 0.4.3 (the TCB behind PR #15420)

Integrity: Downloaded artifact sha256 = 8ba43a8b…54e3 — exact match to the PR's Cargo.lock pin. This is the code that would ship. Self-contained (all C sources bundled; no build-time network/git fetch).
Authored by Mariano Abad / fxd0h — the same person as the PR submitter (so for RustDesk, this privileged code has had no independent third-party review; you are the reviewers).

Architecture (verified from source)

drmtap_open with no CAP_SYS_ADMIN → library socketpair(AF_UNIX) + fork/execs drmtap-helper with the child socket on fd 3. The helper opens /dev/dri/cardN, reads the scanout, and returns either a DMA-BUF fd
(SCM_RIGHTS) or raw pixels. It's a private per-process child over a private socket — there is no shared/ambient daemon socket other users could connect to.

What's genuinely well-done ✅

  • Confinement is fail-closed and unconditional. build.rs hardcodes -DHAVE_LIBCAP=1 -DHAVE_SECCOMP=1 and links -lcap -lseccomp with assert!(status.success()). There is no path that silently builds an
    unconfined helper — it either builds hardened or fails to build. (My biggest worry going in; cleanly resolved.)
  • Helper hardening (main()): PR_SET_NO_NEW_PRIVS → drop all caps except CAP_SYS_ADMIN → install a default-KILL_PROCESS seccomp allowlist (~20 syscalls: no execve, no socket/connect/clone, no ptrace). Each
    step hard-fails (refuses to run) if it can't apply. So the helper cannot phone home or exec even if cap_sys_admin is held.
  • Device allowlist: the cap_sys_admin open() is gated by device_path_allowed() — realpath() + must resolve under /dev/dri/. So the broad capability can't be turned into "open arbitrary device/file" via
    DRM_DEVICE/argv.
  • No injection vectors: helper located only via root-owned absolute paths (no $PATH/cwd), execl (no shell), device_path not env-overridable on the client side (RustDesk passes helper_path = NULL).
  • Client-side bounds checks: data_size > buf_size → drain + ENOSPC; cursor payload capped at 256×256×4. Trust direction (unprivileged client distrusts sizes) is correct.

🔴 Central residual risk — file capability on a world-executable helper

This is the real finding, and it's deployment, not code:

  • postinst runs setcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper on a binary installed mode 0755 (world-executable). A file capability applies to every user who execs it. So any local unprivileged
    user can run the helper directly and read the DRM scanout — the login/greeter screen (where another user types their password), the lock screen, and other users' sessions (cap_sys_admin reads the active
    CRTC's framebuffer regardless of DRM-master/session ownership).
  • The helper's main() checks only that fd 3 is open ("must be spawned by libdrmtap, not run directly"). This is not authentication — ./drmtap-helper 3<>/dev/null or any attacker-provided socketpair
    satisfies it. The comment overstates the protection.
  • On a single-user desktop this is largely moot (you can already see your own screen). On multi-user / shared / server hosts it's a genuine local information-disclosure primitive, and cap_sys_admin is a
    heavy capability to expose to all local users (seccomp confines the helper's own behavior, but the exposed function — read any scanout — is itself the sensitive part; a memory bug would compound it since
    open+ioctl+mmap remain allowed under cap_sys_admin).

Mitigations (the actionable ones live in RustDesk's packaging, which the PR controls):

  1. Restrict who can exec the cap'd helper — install it root: 0750 instead of world-exec, so only root + a trusted group can run it (add this to postinst/build.py). On single-user systems the session
    user joins that group; on multi-user you've at least made "can screen-capture" an explicit group membership rather than "everyone."
  2. Document the multi-user exposure prominently and frame drm as intended for single-user/unattended hosts.
  3. (Upstream, optional) the helper could check the caller's logind session / DRM-master and refuse a framebuffer from a different session — but that's a libdrmtap change.

🟡 Secondary findings

  • Pin exactly. Cargo.toml uses libdrmtap-sys = "0.4.3" (^0.4.3). The lock pins the checksum (good), but for a privileged TCB I'd use =0.4.3 so a lock regen can't pull a new 0.4.x.
  • Tighten seccomp (defense-in-depth): open/openat are allowed for the whole loop but the device is opened before the loop — dropping them post-init would remove the most dangerous post-exploitation
    primitive (cap_sys_admin + open(/dev/mem) + mmap). Worth raising upstream.
  • No exploit-mitigation flags on the helper compile (no -fstack-protector-strong, -D_FORTIFY_SOURCE=2, -fPIE -pie, RELRO) — it's a privileged binary; these are cheap hardening.
  • O_RDWR on the DRM node where capture only needs read (minor; not exposed as a modeset command over IPC).
  • Code-quality smell: pervasive copy-pasted perror("cap_set_proc failed") on unrelated errors (seccomp init, send failures, etc.) — misleading diagnostics; signals the helper was written quickly. Also
    drmtap_version() returns 0x000100 (0.1.0) while the crate is 0.4.3.
  • Low ecosystem usage / brand-new (2026) → minimal external scrutiny; you're trusting author-authored privileged C.

@rustdesk

rustdesk commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Security concern

The core concern with this PR is not whether DRM/KMS capture works technically; it likely does. The security issue is that it intentionally bypasses Wayland’s portal-based consent model by introducing a privileged drmtap-helper with cap_sys_admin+ep.

That changes RustDesk’s threat model significantly.

The main risks are:

  1. The trusted computing base moves outside this PR.
    The critical security behavior is in libdrmtap-sys / drmtap-helper: caller authentication, IPC handling, seccomp restrictions, DRM device access, and framebuffer bounds checking. This PR should not be accepted from a security perspective without auditing that helper.

  2. A world-executable file-capability helper is dangerous on multi-user systems.
    If /usr/lib/rustdesk/drmtap-helper is installed as mode 0755 with cap_sys_admin+ep, any local user can execute it. That means a normal unprivileged user may be able to read the active DRM scanout, including the login screen, lock screen, or another user’s graphical session.

  3. CAP_SYS_ADMIN is too broad.
    Even if the helper is small, any memory-safety bug, ioctl misuse, IPC parsing bug, or seccomp gap could become a serious local privilege-escalation issue.

  4. The feature is safe only under a narrow deployment model.
    On a single-user, physically controlled unattended machine, this may be an acceptable tradeoff. On shared workstations, enterprise desktops, labs, or any host with multiple local users, it creates a new local information-disclosure primitive.

Recommended changes:

  1. Keep DRM capture off by default and do not enable it in standard release packages.

  2. Do not install a world-executable cap_sys_admin helper. Use a restricted permission model, for example:

chown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper
chmod 0750 /usr/lib/rustdesk/drmtap-helper
setcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper

Only explicitly authorized users or the RustDesk service account should be in that group.

  1. Consider replacing the file-capability helper with a systemd root service that exposes a narrow, access-controlled IPC API to RustDesk.

  2. Require strong caller validation, for example SO_PEERCRED, expected UID/session checks, or a RustDesk-owned private socket with strict filesystem permissions.

  3. Document the threat model clearly: this mode bypasses Wayland portal consent and may expose login/lock/other-user screens if installed incorrectly.

  4. Audit libdrmtap-sys / drmtap-helper before enabling this in any official build.

Suggested conclusion:

I think the approach is technically valid for unattended access, but it should be treated as a privileged host-mode feature, not a normal Wayland capture backend. I would not ship it enabled by default unless the helper is access-controlled and the external helper code has been security-reviewed.

@rustdesk

rustdesk commented Jul 22, 2026

Copy link
Copy Markdown
Owner

main+sha.

Use this.

.so sha256

NO

/proc/exe mismatch test

Can ignore this.

@fxd0h

fxd0h commented Jul 22, 2026

Copy link
Copy Markdown
Author

oh well, it looks like its ready for your merge call

fxd0h added 23 commits July 22, 2026 14:29
adds an opt-in `drm` feature for unattended remote access on Wayland: it
captures below the compositor via libdrmtap, so there is no
xdg-desktop-portal consent dialog and it works at the login screen.

off by default. when the feature is off the build is byte-identical.
everything is gated behind feature = "drm" or lives only in the separate
rustdesk-unattended-wayland deb, whose package name is the informed consent.

architecture (agreed with the maintainer): the capture runs inside the root
--service, which already holds the privilege it needs, and streams frames to
the user --server over a service-scoped _drm ipc channel. libdrmtap is loaded
with dlopen at runtime (no link-time dependency, so the base build is
unchanged and it still runs on ubuntu 18), and the .so is built in ci from the
rustdesk-org/libdrmtap fork and shipped only in the drm deb. no setcap helper.

- service: DrmReader reads scanout directly via the dlopen loader; an
  IpcDrmCapturer serves _drm consumers with a per-connection capture worker;
  durable availability cache + pre-warm to avoid enumerate/re-probe restarts
- capture: multi-display (targets the selected crtc), hardware cursor over
  _drm, transient-errno retry with a bounded stall, rejects non-32bpp scanouts
  before the frame copy
- robustness: only active, crtc-bound outputs are offered (an unbound
  crtc_id=0 connector is filtered and a client-selected 0 is refused, both
  fall back to pipewire); a per-display rapid-rebuild guard demotes a flapping
  display to pipewire; per-display (not global) zero-frame failure tracking
- root-service hardening: bounded frame allocation and a concurrent-connection
  cap so a malformed scanout or a buggy consumer cannot OOM or thread-exhaust
  the service; a negative availability verdict expires so displays that appear
  after startup recover without a --server restart; exactly-one .so selection
  in the packaging so a stale object is never silently shipped
- build: libdrmtap.so cloned at build time from rustdesk-org/libdrmtap main
  and bundled only for the --drm deb; ci builds a separate
  rustdesk-unattended-wayland deb (incl. an ubuntu 18.04 container)
- DRM_CAPTURE_SECURITY.md: threat model and hardening notes
…d frame

move the egl detile and rgba pack out of the root --service and into the
unprivileged --server. the root now calls only drmtap_open + drmtap_grab_desc
and exports a raw dma-buf fd; the fd rides the _drm channel over SCM_RIGHTS with
a small descriptor (geometry, per-plane offsets/pitches, modifier, hdr) instead
of the full rgba frame, dropping the per-frame copy. the --server imports the fd
with drmtap_open_render + drmtap_convert_dmabuf, keyed by the import-once egl
cache, and the render context is created and dropped on the recv thread.

the _drm transport moves off Framed<BytesCodec> (which cannot carry a fd) to a
bespoke sendmsg/recvmsg framing (DrmConn) that attaches one SCM_RIGHTS cmsg only
when a fd is present and rejects a truncated ancillary message. the split
symbols are bound optionally so an older libdrmtap still loads the cpu path, and
the whole thing degrades to the cpu BGRA path or PipeWire when no render node is
available. pins libdrmtap-sys to =0.4.13 with the Cargo.lock checksum. folds in
the DP-MST, ldconfig-restart and per-display PipeWire-fallback review fixes and a
udev hotplug refresh.
1- do not depend on the libdrmtap-sys crate for the pin: its build.rs statically
compiles the whole libdrmtap C tree and a CAP_SYS_ADMIN helper and links
-ldrm/-lseccomp/-lcap, which defeats the runtime-dlopen model. keep drm a pure
dlopen backend and pin the .so by the build.py DRMTAP_REF release tag, guarded by
a strict vX.Y.Z regex. drops the now-moot Cargo.lock freshness CI checks.
2- render-node-less consumers no longer lose the stream: the --server signals
need_cpu on DrmStart when it cannot open a convert context, and the --service
streams the CPU-converted frame path for that connection instead of a dma-buf fd
the consumer cannot detile (which used to fall through to a PipeWire path nobody
can approve on an unattended seat).
3- mark PipeWire initialized only after every per-display capturer is created, so
a partial failure retries instead of the flag falsely reporting a complete init.
4- reject a degenerate (zero width/height) or short CPU frame before it reaches
PixelBuffer::new (which derives stride as data.len()/height, dividing by zero).
5- keep the export-ledger epoch at DRM_DISPLAY_GENERATION so a hotplug invalidates
cached buffers (elision stays off until the recycled-fb_id inode case is handled).
6- validate the udev uevent source (kernel nl_pid, multicast) with recvmsg so a
local process cannot unicast a spoofed drm-change event to the root listener.
1- make PipeWire init atomic: build every per-display capturer into owned staging
first and publish them to CAP_DISPLAY_INFO only after all succeed, so a mid-loop
Capturer::new failure neither leaves partial entries (which the next check_init
would treat as already-initialized) nor leaks the raw pointers already created.
2- pin the immutable libdrmtap commit, not just the tag: git clone --branch
follows a mutable tag, so verify the cloned HEAD equals DRMTAP_SHA in both the CI
workflow and build.py, failing on a moved/compromised tag.
3- drop the stale comment claiming a libdrmtap-sys crate pin (the drm backend has
no such dependency).
1- verify the commit-SHA pin on a reused checkout too, not only on a fresh clone:
a stale or mismatched third_party/libdrmtap (e.g. from a failed clone) is now
removed and the build fails instead of silently reusing unpinned source.
2- default DRMTAP_REPO to the fork that actually publishes the pinned tag, so a
clean git clone --branch v0.4.13 resolves (and to the expected commit) instead of
failing on a repo that does not carry the tag.
do not let an inherited DRMTAP_SHA override the verified commit in CI, so the
tag/commit pair is immutable there. build.py keeps the env override for local
forks.
gate the commit-SHA pin check on third_party/libdrmtap being a git checkout, so a
clone (fresh, reused, or a stale/failed one) is still verified, but a non-git
source tree a developer placed there on purpose to build unreleased local
libdrmtap is used as-is (it has no tag to verify).
since libdrmtap 0.4.11 the project builds both a shared object and a static
archive, so 'meson compile drmtap' is ambiguous. ask for drmtap:shared_library
(rustdesk dlopens the .so and never links the archive).
grab_desc exports the raw scanout dma-buf; the unprivileged converter handles
every format libdrmtap supports (10-bit XR30/AR30 with tone mapping, HDR, CCS)
down to RGBA. The fourcc gate copied from the CPU-mapped grab() wrongly closed
the _drm stream for a 10-bit XR30 primary (0x30335258) that convert_dmabuf
converts fine -- observed live on an i915 seat scanning out XRGB2101010. Keep
the gate only on grab(), whose frame.format is already the converted BGRA.
DRM and PipeWire do not share a display-index space: DRM enumerates one entry
per connector while the portal often exposes a single whole-desktop stream at
index 0. When a per-display DRM capture was demoted to PipeWire for a non-primary
DRM index, cap_map.get(&display_idx) was None and the bail Err made
ServiceTmpl::run retry get_capturer every 1s forever (a multi-monitor restart
loop, latent until a display demotes). Degrade to the whole-desktop stream
(index 0) PipeWire does provide instead of spinning. Healthy DRM displays return
before this and are unaffected.
the CI .so-prebuild step used the same bare 'drmtap' meson target that is
ambiguous since libdrmtap became both_libraries (0.4.11); ask for
drmtap:shared_library, matching build.py.
3.2: get_capturer_for_display no longer falls back to cap_map[0] for a missing
index. CapturerPtr is a bare *mut Capturer cloned by raw-pointer copy, so aliasing
one entry to two display_idx values let two video-service threads call frame() on
the same Recorder unsynchronised (data race / UB), reachable in a plain build via
CaptureDisplays{set:[0,3]}. Restore the exact-index lookup + bail; a demoted DRM
index is dropped from the advertised list at the source instead.
4.6: revert check_init to upstream (flag set before the per-display loop, direct
insert). The staged-all-or-nothing variant turned a partial per-display failure
into a permanent 1Hz retry loop and was not drm-gated. Both restore the drm-off
build to byte-identical with upstream.
3.1: snapshot the stock flutter bundle before the CI drm relink and restore it
before makepkg, so the official Arch package ships the stock cdylib, not the
drm-enabled one. 4.2: wrap the drm block in a failure-tolerant subshell so a
drm-only failure no longer aborts the stock deb/rpm/arch publish. 4.3: narrow the
publish glob to rustdesk-[0-9]*.deb so the consent-bypass unattended-wayland deb
stays an artifact, not on the public release. 4.4: rewrite the three stale
DRM_CAPTURE_SECURITY.md statements to the split (default path passes a read-only
scanout dma-buf fd over SCM_RIGHTS with an import-once cache; export validation is
metadata-only; BGRA-over-the-wire is the fallback) and document that grab_desc's
fd is O_RDONLY (DRM_RDWR dropped upstream, dup preserves it). 4.7: only
short-circuit to the DRM cursor when it is authoritative (visible, or hidden in a
pure-DRM session); fall through to the normal cursor path in a mixed
DRM+PipeWire session. minors: thread the deb variant by feature not glob; TODO
for the ld.so.conf.d system path; drop a stray blank line. All gated or
whitespace so the drm-off build stays byte-identical.
…view 3.3, 4.1)

3.3: DRM/KMS capture is not session-scoped -- the worker grabs a CRTC's physical
scanout regardless of which session owns the display -- but the peer was
authorized only once at accept. Capture the peer uid and re-check it at the top of
the forward loop: root is always allowed, any other peer must still be the
active-session uid, fail closed otherwise. A session change now tears the stream
down within one frame (~33ms) instead of leaking the incoming user's screen to the
outgoing user's --server.
4.1: connect_drm accepted any producer. Reject a non-root peer (peer_uid != 0) so a
process that won the socket-path race cannot feed the consumer a display list,
frames and dma-buf fds while the DRM path suppresses the portal consent prompt.
…ins (review 4.1, 4.8)

4.1: the DrmCursor consumer handed the wire body straight to the client, which
renders width*height*4 RGBA bytes. Reject a body shorter than that so a truncated
cursor cannot make the client read past the buffer. The hidden-cursor sentinel is
0x0 with an empty body, for which the bound is 0 and the check is a no-op.
4.8: the _drm socket is a FIFO, so a consumer that drains slower than we produce
(a 4K convert on a modest GPU) fell seconds behind stale frames. Drain the producer
channel without blocking each tick and forward only the newest frame; replaced
frames drop in place, closing the zero-copy OwnedFd and freeing the CPU-path pixel
buffer. Cursor updates stay in order and are never coalesced away.
…ipeWire (review 4.5)

A DRM display demoted to PipeWire has no geometry-consistent per-connector stream
on a multi-monitor host -- the portal exposes a single whole-desktop stream. The
fallthrough served that whole-desktop frame while the list still advertised the
demoted connector geometry, so the client stretched the frame and offset all input
by the connector origin (the primary-index-0 demotion reaches this even after the
get_capturer_for_display exact-index fix).

Dropping the display from the list is not an option: its position IS the capturer
index, so a drop would shift every later display and desync get_capturer_info. So
instead: get_display_infos advertises a multi-monitor demoted display OFFLINE at its
stable index, and get_capturer_for_display serves the PipeWire fallback only when
its rect matches the advertised geometry, else bails. A single-display host still
falls through (whole-desktop == that display). All new logic is drm-gated.
…a stale verdict, drop dead clear (review 5)

- recv_msg_timeout2 only gated the wait for the first byte, so a peer that sent one
  byte then stalled pinned the task forever. The same budget now also bounds the body
  read; a body that overruns is a hard error that tears the stream down (recv_msg
  bodies are small JSON, so a healthy peer never trips it).
- The cursor cache is keyed by display index, which a rebuilt stream reuses, so a
  predecessor exiting after its replacement published a fresh cursor erased it. Stamp
  each entry with a monotonic per-stream epoch and compare-and-remove on teardown.
- ProbeState::Available had no TTL, so an idle hotplug left a phantom display in
  enumeration. Give it a timestamp and refresh the list off the hot path once it ages
  past POSITIVE_TTL. The verdict stays true across the refresh (never bounces a live
  session to the portal) and the probe runs on a background thread (never blocks the
  async enumeration).
- Remove the dead clear(): it is unreferenced, and wiring it into teardown would force
  the blocking re-probe on the next enumeration that swap_available_displays exists to
  avoid.
The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame
first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero
tests. Add pure-userspace coverage over a socketpair:
- a control message round-trips with and without an attached fd, and the received fd
  refers to the same open file (a byte written into the source is read back through it)
- a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte
- a forged length prefix past the JSON cap is rejected at the prefix
- surplus fds packed into one cmsg keep only the first and close the rest
- a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed
- peer_uid_from_fd reads the socket peer credential the producer-auth path relies on
Five defects an adversarial pass found in the previous commits:
- refresh_available_async set the single-flight probe guard, then relied on the
  detached thread to clear it; if thread creation failed (EAGAIN) or the closure
  unwound, the guard leaked true and froze every future probe. Release it via RAII
  inside the closure and on a Builder::spawn error.
- The _drm per-frame re-auth called the cached active_uid(), which on a cache miss
  (exactly during a session switch) falls back to a blocking loginctl seat0 lookup --
  on the single-threaded _drm runtime, once per frame, a subprocess storm. Use a new
  cache-only accessor that never blocks and fails closed on a miss, and correct the
  comment: the stop is bounded by the active-uid cache cadence, not one frame.
- set_drm_cursor inserted unconditionally, so a still-draining predecessor stream
  could overwrite (then delete on teardown) the cursor a replacement stream published
  for the same index. Make it a compare-and-set that ignores an older epoch.
- recv_msg_timeout2 treated a spurious readable() wakeup with nothing consumed as a
  mid-frame stall and tore the stream down. Track whether any byte was consumed
  (drm_read_full sets it) and map a zero-progress deadline back to None (re-poll),
  reserving the hard error for a genuine partial-frame stall.
The cold availability probe in is_available acquired DRM_PROBE_IN_FLIGHT and released
it with a plain store(false) after a synchronous body; a panic there (e.g. a poisoned
DRM_STATE lock) would leak the guard true and freeze both future probes and the
refresh path hardened in the previous commit, since they share the guard. Hoist the
release into a shared ProbeInFlightGuard used by both the cold probe and the refresh
closure, so any exit -- normal, early, or unwinding -- clears it.
The dlopened .so is loaded into the CAP_SYS_ADMIN root service, so it should come
from the maintainer-owned repo, not a personal fork. rustdesk-org/libdrmtap main is
already synced to the exact commit we pin (c9cf0938 = v0.4.13) but carries no release
tag, so point both build.py and the CI job at rustdesk-org and track main with the
immutable commit pinned via DRMTAP_SHA. The post-clone sha check makes this
fail-closed: main moving off the pinned commit fails the build instead of silently
swapping the .so. The CI ref guard now accepts a vX.Y.Z tag or main (a loose branch is
still rejected). Switch DRMTAP_REF to a tag if rustdesk-org later publishes one.
… and re-auth (review 5e, 6a)

5e: the deb dropped /usr/lib/rustdesk into /etc/ld.so.conf.d so the private libdrmtap
could be found by soname -- a system-wide search-path entry that lets it shadow a
system library for every binary on the host, which Debian Policy 10.2 forbids. Resolve
it by absolute path (/usr/lib/rustdesk/libdrmtap.so.0) at the dlopen site instead, with
the bare sonames kept only as a dev fallback, and drop the ld.so.conf.d file and the
ldconfig/try-restart postinst entirely (the .so is present at its absolute path right
after unpack, so the pre-warm resolves with no linker-cache step). The dlopen site is
this PR's own code, so this is in scope, not a follow-up.

6a: extract the _drm admission bound and the per-frame re-auth decision into pure
helpers (drm_conn_admitted, drm_peer_authorized) and unit-test them: admission admits
strictly below MAX_DRM_CONNS and rejects at/above it; re-auth passes root always,
passes a non-root peer only while it equals the active-session uid, and fails closed on
a switched-away, unknown-session, or unknown-peer case. (The /proc/exe-mismatch
rejection is exercised by the accept-time authorize call; unit-testing it in isolation
would need a second process with a different exe, so it stays an integration concern.)
The _drm unit tests are behind the opt-in drm feature, which the default workspace
test job does not build, so they would sit in the tree unrun -- no better than no
tests. Add a Linux step to the per-PR ci.yml that runs them with the feature on,
alongside the existing ipc/auth tests. drm is a pure runtime-dlopen backend with no
link-time deps (no libdrm/EGL/gbm) and the tests are pure userspace (socketpair
framing, SCM_RIGHTS, the peer-auth/admission decisions), so this needs no GPU and no
extra system packages. The main build/test stays on default features, so the shipped
drm-off config remains the primary verified one.
@fxd0h
fxd0h force-pushed the feature/drm-capture-clean branch from 9804c21 to 82ec881 Compare July 22, 2026 17:30
@rustdesk

Copy link
Copy Markdown
Owner

Review

The patch leaves the conversion path privileged on login screens and can accumulate an unbounded stale-frame FIFO under realistic throughput. Hotplug, mixed-backend cursor handling, and multi-GPU selection also contain functional gaps.

Review comments

[P1] Keep login-screen conversion out of the root server

Location: src/server/drm_capturer.rs:262

On GDM/SDDM Wayland login screens, start_os_service calls start_server(None), which launches --server directly as root. Opening RenderConverter here therefore loads EGL/GLES and the vendor GPU driver and converts DMA-BUFs in a root process, defeating the privileged-export/unprivileged-convert split for a core advertised scenario; run the converter under the greeter/session UID or isolate/drop privileges first.

[P1] Drop stale frames before they enter the socket FIFO

Location: src/ipc.rs:2225

When DMA-BUF conversion and copying sustain less than the fixed 30-fps producer rate, this awaited send queues every descriptor and fd in the Unix socket before the consumer's latest-wins slot can discard anything. Once the socket fills, draining the two-entry MPSC queue only inserts fresh frames behind the existing backlog, leaving the remote desktop permanently seconds behind; use socket-level acknowledgements/nonblocking dropping or otherwise ensure only the latest frame enters the wire queue.

[P2] Publish an empty topology after the last CRTC disappears

Location: src/ipc.rs:2177

When the last active monitor is unplugged, the uevent listener replaces DRM_DISPLAY_CACHE with an empty list and bumps the generation, but this condition suppresses the only notification to consumers. The positive refresh path also ignores empty probe results, so DRM_STATE continues advertising the removed displays indefinitely; send the empty update and transition the consumer out of Available.

[P2] Refresh logical geometry and uinput bounds on hotplug

Location: src/server/drm_capturer.rs:419

When a monitor is added or removed while a DRM stream remains alive, this swaps only the raw DRM list. The Wayland logical display list remains process-cached and the uinput absolute range is updated only during initialization, so subsequent display reports use stale positions/scales and injected coordinates target the old desktop layout until all video services tear down; invalidate/reload the Wayland cache and reapply the mouse range with this update.

[P2] Detect PipeWire-backed displays by backend, not list length

Location: src/server/display_service.rs:326

In a mixed session where one DRM display is demoted and served through a geometry-compatible PipeWire entry, SYNC_DISPLAYS is still overwritten with this same DRM list and demotion preserves every list position, merely marking one offline. The lengths therefore remain equal and this returns false, causing a hidden DRM cursor sentinel to hide the cursor globally on the PipeWire display; track which indices are actually DRM-backed instead.

[P2] Match the render node to the exported scanout buffer

Location: libs/scrap/src/common/drm_render.rs:72

On multi-GPU systems, drmtap_open_render(NULL) in the pinned library selects the first openable renderD*, which need not belong to the GPU that exported the received DMA-BUF. When cross-device import is unsupported, conversion fails permanently and this stream is rebuilt/demoted to PipeWire even though the matching render node or the CPU path would work; select the render node from the exported buffer/device or retry with need_cpu after this failure.

@rustdesk

Copy link
Copy Markdown
Owner

Review of libdrmtap APIs Used by RustDesk

Scope

This review covers the libdrmtap v0.4.13 commit pinned by RustDesk PR
#15420:
c9cf0938f3b10a3d4a9eeb9c6f97aaa1606c6b4a.

The RustDesk integration resolves or calls these public entry points:

  • drmtap_version
  • drmtap_open
  • drmtap_close
  • drmtap_list_displays
  • drmtap_grab_mapped
  • drmtap_frame_release
  • drmtap_grab_desc
  • drmtap_open_render
  • drmtap_convert_dmabuf
  • drmtap_get_cursor
  • drmtap_cursor_release

The findings below are limited to defects or API gaps in those libdrmtap call
paths that affect the RustDesk integration.

Review comments

[P1] Close GEM handles created by cursor polling

Location: src/cursor.c:185

drmtap_get_cursor obtains a framebuffer through drmModeGetFB2, exports
fb2->handles[0] as a PRIME fd, closes the PRIME fd, and frees the FB2
structure, but never closes the GEM handle with DRM_IOCTL_GEM_CLOSE.
RustDesk polls this function once per captured frame while the hardware cursor
is visible, so the root service continuously accumulates kernel GEM handles
until process or kernel resources are exhausted. Close every unique nonzero
handle returned by drmModeGetFB2 on all exit paths.

[P1] Propagate unsupported detiling instead of returning raw scanout bytes

Location: src/drm_grab.c:972

After EGL import fails, a CCS or otherwise unsupported compressed modifier
causes drmtap_deswizzle to return -ENOTSUP. gpu_auto_process suppresses
that error, changes the modifier to linear, and returns success while leaving
the frame data unconverted. drmtap_convert_dmabuf consequently reports raw
compressed bytes as valid linear pixels, and RustDesk forwards a corrupt
frame instead of ending the DRM stream and falling back to PipeWire. Preserve
and return the conversion error whenever no path produced linear pixels.

[P2] Enumerate active displays on every DRM card

Location: src/drmtap.c:91

RustDesk opens every enumeration and capture context with a null device path.
drmtap_open(NULL) returns as soon as it finds the first card with an active
CRTC, and drmtap_list_displays can then enumerate only connectors on that
card's fd. On systems whose monitors span multiple GPUs, displays driven by
later cards are never advertised, and their card-local CRTC ids cannot be
opened. Provide multi-device enumeration, or require the caller to enumerate
all KMS cards while retaining the originating device identity in every
display and capture request.

[P2] Bind the render context to the device that exported the DMA-BUF

Locations: src/drmtap.c:323,
src/gpu_egl.c:524

RustDesk calls drmtap_open_render(NULL), which selects the first openable
renderD* rather than the render node associated with the exported scanout
buffer. In addition, libdrmtap initializes one process-global EGL display from
the first context and reuses it for later contexts, even when they target
another GPU. Cross-device modifier import may therefore fail permanently on a
multi-GPU host despite a usable matching render node. Carry the exporter
device identity with the descriptor, select its render node, and manage EGL
displays per device rather than globally.

[P2] Resolve HDR connectors through atomic state

Location: src/drm_grab.c:113

read_hdr_metadata_direct associates a connector with the selected CRTC only
through the legacy encoder's crtc_id. Atomic Wayland compositors may report
zero there even though the connector's atomic CRTC_ID property identifies
the active CRTC. Display enumeration already implements that fallback, but
the direct HDR path does not. drmtap_grab_desc then labels a PQ scanout as
SDR, so RustDesk reduces XR30 or AR30 without tone mapping and displays a
washed-out image. Reuse the atomic connector-to-CRTC resolution in the HDR
lookup.

[P2] Preserve the native format and plane layout in EGL import retries

Location: src/gpu_egl.c:787

If EGL rejects the real DMA-BUF attributes, the retry path reinterprets every
source as XRGB8888, resets plane 0's offset to zero, drops auxiliary planes,
and finally removes the modifier. If a retry succeeds for an XR30/AR30,
16-bit, XBGR, offset scanout, or compressed buffer, the shader samples the
wrong bit packing, memory region, or layout and reports successful
conversion. Retrying must not change the buffer's native fourcc, offsets,
pitches, plane count, or modifier; if the real layout cannot be imported,
return an error and use a valid fallback.

[P2] Synchronize cursor DMA-BUF CPU reads

Location: src/cursor.c:196

The cursor path mmaps and copies the exported DMA-BUF without the
DMA_BUF_IOCTL_SYNC START/END pair used by the main-frame path. DMA-BUF CPU
access is not guaranteed to be coherent, so non-coherent exporters such as
ARM and Jetson can return stale or partially updated cursor pixels. RustDesk's
content hash may then suppress the real update. Bracket the mapped read with
DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ and the matching END operation, and
handle retryable synchronization errors.

Validation

This was a static review of the exact pinned libdrmtap and RustDesk PR commits.
The review environment did not have Meson installed, so the libdrmtap unit
tests and hardware-dependent DRM tests were not executed.

@fxd0h

fxd0h commented Jul 24, 2026

Copy link
Copy Markdown
Author

thanks, went through both reviews. status below - it is all on the branch now and libdrmtap 0.4.15 is published (crates.io + tag v0.4.15 on my fork).

rustdesk consumer (6):

  • login-screen convert out of root: the convert only opens under the session server. on the gdm/sddm greeter path the server also adopts the root config through the config sync, so it never loads the vendor gpu stack as the greeter uid.
  • stale-frame fifo: added credit/ack flow control on the _drm socket. the producer only sends while it holds credit and coalesces to the latest frame, so the socket never backs up under a slow convert.
  • empty topology on last-crtc-unplug: the empty list is now pushed and the availability state drops out of Available, so a removed display stops being advertised even on an idle host with no live stream to carry the push.
  • hotplug geometry + uinput: a hotplug now reloads the wayland logical list and reapplies the uinput absolute range, not just the raw drm list.
  • pipewire-backed detection: solved a bit differently than by list length. a demoted display is marked online:false at its stable index so positions do not shift, and the cursor no longer hides globally off a list-length compare. covers the mixed-session case you described without a separate drm-backed index map.
  • render node match: done both ways you offered. the service names the exporting gpu render node per display and the converter binds it, and if that node cannot be opened it falls back to the need_cpu path so the convert runs on the exporting gpu service-side.

libdrmtap (6, you reviewed 0.4.13):

  • cursor gem handle leak: fixed in 0.4.14.
  • propagate unsupported detiling: fixed in 0.4.15. do_grab now returns the -ENOTSUP instead of the raw compressed bytes relabelled linear, and it was discarded at three call sites which is why the fail-closed was defeated.
  • enumerate every card: 0.4.15 adds drmtap_list_devices, and the rustdesk service now opens one context per card and merges the lists, each display tagged with its own card + render node so DrmStart reopens the right device.
  • bind the render context to the exporting device: 0.4.15 adds drmtap_render_node + per-device egl displays + a sysfs-ranked node auto-select. one deviation worth flagging: i did not carry the device identity inside drmtap_dmabuf_desc. that struct is written into caller-owned storage and the consumer dlopens the .so by soname, so growing it would corrupt a consumer built against an older header. i kept the descriptor frozen and exposed the node as a separate accessor, which a dlopen consumer reads as absent on an older .so.
  • egl import-retry layout: fixed in 0.4.15. the retry no longer reinterprets a non-8bit-rgb or multi-plane source as xrgb8888, and the final no-modifier retry is gated to a linear source.
  • cursor dma-buf sync: fixed in 0.4.15. the read is bracketed with DMA_BUF_IOCTL_SYNC in both the direct path and the helper.

hardware honesty: the render-node selection is verified on a jetson orin with two real drm devices (tegra with no connectors, nvidia-drm driving the display) - selection moves to the exporting node. the multi-card display merge, one monitor per gpu on two display-driving gpus, is implemented but i have no such box to verify it end to end, so that part is code + unit tested only. everything else is verified live on an intel i915, three monitors over the dma-buf split, no leak.

one thing i need from you: rustdesk-org/libdrmtap is still at 0.4.13 (c9cf0938). can you sync it to 0.4.15 (my main is cbc5e6a, tag v0.4.15) so the build can pin there instead of my fork? once that is in i will bump the pin and push the reworked branch to the pr.

@rustdesk

Copy link
Copy Markdown
Owner

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants