mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-07 21:11:05 +03:00
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:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user