Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/flutter-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,26 @@ jobs:
mv "$name" /workspace/"${name%%.rpm}-suse.rpm"
done

# --- opt-in unattended-wayland (DRM/KMS) variant: a separate deb ---
# Ships the privileged drmtap-helper so enabling consent-free capture
# is an explicit install choice (the package name states what it does).
# Built last so the drm relink can't leak into the stock deb/rpm above.
# x86_64 only (the unattended/kiosk/server use case); the package
# Conflicts/Replaces the stock rustdesk package (install one or other).
if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then
pushd /workspace
echo -e "start packaging unattended-wayland (DRM) deb"
# drm-only build deps, installed here (not in the stock install list)
# so the default drm-off build stays identical to upstream
apt-get install -y libseccomp-dev libcap-dev libdrm-dev
cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste,drm --release
python3 ./build.py --flutter --drm --skip-cargo
for name in rustdesk-unattended-wayland*??.deb; do
mv "$name" "${name%%.deb}-${{ matrix.job.arch }}.deb"
done
popd
fi

- name: Publish debian/rpm package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
Expand All @@ -1693,6 +1713,13 @@ jobs:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb

- name: Upload unattended-wayland deb
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
with:
name: rustdesk-unattended-wayland-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
path: rustdesk-unattended-wayland-${{ env.VERSION }}-${{ matrix.job.arch }}.deb

# only x86_64 for arch since we can not find newest arm64 docker image to build
# old arch image does not make sense for arch since it is "arch" which always update to date
# and failed to makepkg arm64 on x86_64
Expand Down
21 changes: 16 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ default = ["use_dasp"]
hwcodec = ["scrap/hwcodec"]
vram = ["scrap/vram"]
mediacodec = ["scrap/mediacodec"]
drm = ["scrap/drm", "dep:libdrmtap-sys"]
plugin_framework = []
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
unix-file-copy-paste = [
Expand Down Expand Up @@ -176,6 +177,11 @@ bytemuck = "1.23"
ttf-parser = "0.25"

[target.'cfg(target_os = "linux")'.dependencies]
# Direct dep so build.rs receives DEP_DRMTAP_HELPER_BIN via the `links` mechanism.
# Built from the rustdesk-org fork of libdrmtap (not crates.io) so the privileged
# helper is compiled from source RustDesk controls, in RustDesk CI. Pinned by rev:
# it is a TCB component, so a bump must be an explicit, reviewed change.
libdrmtap-sys = { git = "https://github.com/rustdesk-org/libdrmtap", rev = "df530502e69183de0b6aba36403ef811eff3a4ef", optional = true }
libxdo-sys = "0.11"
psimple = { package = "libpulse-simple-binding", version = "2.27" }
pulse = { package = "libpulse-binding", version = "2.27" }
Expand Down
69 changes: 69 additions & 0 deletions DRM_CAPTURE_SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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

Capture needs DRM master / `CAP_SYS_ADMIN` to read other clients' framebuffers.
Rather than run RustDesk as root, the `drm` feature ships a small privileged
helper, **`drmtap-helper`** (from
[`libdrmtap-sys`](https://crates.io/crates/libdrmtap-sys)), which carries
`cap_sys_admin+ep` via file capabilities and talks to the unprivileged RustDesk
process over a `socketpair`, passing the scanout back as a DMA-BUF fd.

## 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 TCB is `drmtap-helper` / `libdrmtap-sys`.** The privileged behaviour
(caller authentication, IPC parsing, seccomp, DRM access, framebuffer bounds
checks) lives there and has been security-reviewed. It is hardened:
- validates it was spawned by the library (inherited socket on fd 3) **and**
checks the peer UID via `SO_PEERCRED`;
- restricts the device it opens to a realpath under `/dev/dri/`, opened
`O_RDONLY`;
- `PR_SET_NO_NEW_PRIVS`, drops all capabilities except `CAP_SYS_ADMIN`, then
installs a **default-KILL seccomp allowlist** that deliberately forbids
`open`/`openat` (the device is opened once before the filter loads), so a
compromised helper cannot open arbitrary files even with `CAP_SYS_ADMIN`;
- built with stack-protector-strong, FORTIFY, PIE and full RELRO;
- integer-overflow / DoS size guards on the framebuffer geometry.
- **`CAP_SYS_ADMIN` is broad.** The helper is deliberately tiny and confined as
above, but any unfound bug in it is a local-privilege concern — hence the
access-controlled install below.

## Deployment

- **Off by default.** The `drm` feature is **not** in the default feature set and
is **not** enabled in standard release packages. Build it explicitly with
`python3 build.py --flutter --drm` (Linux only).
- **Not world-executable.** The `.deb` postinst installs the helper as:

```bash
groupadd -r rustdesk-capture
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 members of `rustdesk-capture` can run it; an administrator opts users in
with `usermod -aG rustdesk-capture <user>`. Everyone else (and every host
where the group is empty) transparently falls back to the PipeWire/portal
path. There is no window where the binary is both `0755` and capability-bearing
(mode/owner are set before `setcap`).
- **Recommended for** single-user, physically-controlled, or unattended hosts.
On shared/multi-user hosts, only add trusted operators to `rustdesk-capture`;
the group is the access-control boundary.

## Auditing

```bash
getcap /usr/lib/rustdesk/drmtap-helper # expect: cap_sys_admin=ep
getfacl /usr/lib/rustdesk/drmtap-helper # expect: root:rustdesk-capture 0750
setcap -r /usr/lib/rustdesk/drmtap-helper # revoke the capability
```
90 changes: 81 additions & 9 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ def make_parser():
action='store_true',
help='Build with unix file copy paste feature'
)
parser.add_argument(
'--drm',
action='store_true',
help='Linux only: build the DRM/KMS capture backend (ships the privileged '
'drmtap-helper, installed 0750 root:rustdesk-capture). Off by default.'
)
parser.add_argument(
'--skip-cargo',
action='store_true',
Expand Down Expand Up @@ -282,29 +288,38 @@ def get_features(args):
features.append('flutter')
if args.unix_file_copy_paste:
features.append('unix-file-copy-paste')
if not windows and not osx and args.drm:
features.append('drm')
if osx:
if args.screencapturekit:
features.append('screencapturekit')
print("features:", features)
return features


def generate_control_file(version):
def generate_control_file(version, extra_depends="", package_name="rustdesk"):
control_file_path = "../res/DEBIAN/control"
system2('/bin/rm -rf %s' % control_file_path)

content = """Package: rustdesk
# An alternative-build package (e.g. the opt-in unattended-wayland / DRM
# variant) installs the same files as the stock `rustdesk` package, so it
# must conflict with / replace it: you install one OR the other, not both.
variant_control = ""
if package_name != "rustdesk":
variant_control = "Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n"

content = """Package: %s
Section: net
Priority: optional
Version: %s
Architecture: %s
Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
%sDepends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.

""" % (version, get_deb_arch(), get_deb_extra_depends())
""" % (package_name, version, get_deb_arch(), variant_control, get_deb_extra_depends(), extra_depends)
file = open(control_file_path, "w")
file.write(content)
file.close()
Expand All @@ -316,6 +331,27 @@ def ffi_bindgen_function_refactor():
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')


def append_drm_helper_postinst():
# Append the helper group + setcap setup to the DRM package's postinst. Only
# the DRM package calls this, so the stock package's DEBIAN/postinst stays
# byte-identical to upstream. cap_sys_admin is granted but kept out of
# world-exec (a world-exec cap_sys_admin binary lets any local user read the
# active scanout, incl. the login/lock screen), so it is restricted to a
# dedicated group before setcap runs.
with open('tmpdeb/DEBIAN/postinst', 'a') as f:
f.write(
'\n'
'if [ "$1" = configure ] && [ -f /usr/lib/rustdesk/drmtap-helper ]; then\n'
'\tgroupadd -f -r rustdesk-capture 2>/dev/null || addgroup --system rustdesk-capture 2>/dev/null || true\n'
'\tchown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper 2>/dev/null || true\n'
'\tchmod 0750 /usr/lib/rustdesk/drmtap-helper\n'
'\tif command -v setcap >/dev/null; then\n'
'\t\tsetcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper || echo "rustdesk: warning: setcap on drmtap-helper failed; DRM/KMS capture will fall back to PipeWire" >&2\n'
'\tfi\n'
'fi\n'
)


def build_flutter_deb(version, features):
if not skip_cargo:
system2(f'cargo build --locked --features {features} --lib --release')
Expand Down Expand Up @@ -352,16 +388,33 @@ def build_flutter_deb(version, features):
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")

# Install drmtap-helper for the privileged DRM/KMS capture path — but ONLY
# when this build actually enabled the `drm` feature. Gating on the feature
# (not on whether a stale ../target/release/drmtap-helper happens to exist
# from an earlier --drm build) keeps the opt-in guarantee for normal packages.
ships_helper = 'drm' in features
if ships_helper:
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
system2('cp ../target/release/drmtap-helper tmpdeb/usr/lib/rustdesk/drmtap-helper')

# A DRM build ships as a separately-named, opt-in package so installing it is
# an explicit choice; the package name itself states what it enables. It is
# an alternative build of the same app, so generate_control_file marks it
# Conflicts/Replaces/Provides rustdesk (install one or the other).
package_name = 'rustdesk-unattended-wayland' if ships_helper else 'rustdesk'
system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version)
# postinst runs setcap (from libcap2-bin) on the helper; make it a hard
# dependency so a DRM package can't install an unusable, cap-less helper.
generate_control_file(version, ", libcap2-bin" if ships_helper else "", package_name)
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
if ships_helper:
append_drm_helper_postinst()
md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;')

system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
os.rename('rustdesk.deb', '../%s-%s.deb' % (package_name, version))
os.chdir("..")


Expand Down Expand Up @@ -389,16 +442,35 @@ def build_deb_from_folder(version, binary_folder):
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
# Install drmtap-helper for the DRM/KMS capture path from the STAGED payload
# (binary_folder), not from ../target/release — that tree may be unrelated or
# stale for a prebuilt / cross-arch bundle. The helper is only in the payload
# when the bundle was built with the `drm` feature, so this stays opt-in. The
# `cp -r` above already placed any bundled drmtap-helper under
# usr/share/rustdesk/; move it to usr/lib/rustdesk/ where postinst applies the
# 0750 + capability.
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
system2('if [ -f tmpdeb/usr/share/rustdesk/drmtap-helper ]; then mv tmpdeb/usr/share/rustdesk/drmtap-helper tmpdeb/usr/lib/rustdesk/drmtap-helper; fi')

system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version)
# Only a bundle staged with the DRM helper needs libcap2-bin (postinst runs
# setcap on it); detect it in the payload so plain bundles stay unaffected.
ships_helper = os.path.exists('tmpdeb/usr/lib/rustdesk/drmtap-helper')
# A staged bundle carrying the helper is the DRM package: name it accordingly
# (Conflicts/Replaces/Provides rustdesk) and run the same helper setcap/group
# setup as build_flutter_deb, so a --package DRM bundle is not left with an
# unusable, cap-less helper under the stock package name.
package_name = 'rustdesk-unattended-wayland' if ships_helper else 'rustdesk'
generate_control_file(version, ", libcap2-bin" if ships_helper else "", package_name)
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
if ships_helper:
append_drm_helper_postinst()
md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;')

system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
os.rename('rustdesk.deb', '../%s-%s.deb' % (package_name, version))
os.chdir("..")


Expand Down
Loading
Loading