diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml deleted file mode 100644 index 2efc6eabd..000000000 --- a/.github/workflows/drm-capture.yml +++ /dev/null @@ -1,449 +0,0 @@ -name: DRM capture (opt-in drm feature) - -# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact -# up/download used by the deb job authenticates with the runtime token, not this one. Declared at -# the workflow level so the reusable bridge workflow called below inherits the same bound. -permissions: - contents: read - -# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to -# record that a given commit on master was verified. -concurrency: - group: drm-capture-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows -# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related -# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. -# -# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is -# the drm-off configuration and that stays the primary verified one. - -on: - workflow_dispatch: - pull_request: - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - push: - branches: - - master - # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push - # that touches only the missing paths (a squash merge, a direct push) skips re-verification. - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - -env: - VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - FLUTTER_VERSION: "3.24.5" - -jobs: - drm-tests: - name: drm unit tests (linux) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y \ - clang cmake curl gcc git g++ \ - libpam0g-dev libasound2-dev libunwind-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name - # filter would skip the sibling asserts that also matter in this configuration, notably the one - # bounding `size_of::()`, which the new DmabufDesc variant grows. - # The two skips are the same ones the stock CI applies: both need a real display server and fail - # on a headless runner regardless of this feature. - - name: Run rustdesk crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ - --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state - - # The capture backend itself lives in the scrap crate, so its unit tests are a separate - # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. - - name: Run scrap crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib - - libdrmtap: - name: libdrmtap pin, build and .so contract - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install libdrmtap build deps - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ - libegl1-mesa-dev libgles2-mesa-dev - - # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or - # moved pin fails here rather than in a release job. - - name: Fetch the pinned libdrmtap and build the .so - shell: bash - run: | - python3 - <<'PY' - import importlib.util, sys - spec = importlib.util.spec_from_file_location("b", "build.py") - b = importlib.util.module_from_spec(spec) - sys.argv = ["build.py"] - spec.loader.exec_module(b) - so = b.build_libdrmtap_so() - print(f"::notice::built {so}") - open("so_path", "w").write(so) - PY - - # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or - # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, - # so assert here that the object we would ship really carries EGL and really exports every symbol - # the runtime loader resolves. - - name: Assert the .so contract (EGL enabled, loader symbols present) - shell: bash - run: | - # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 - # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing - # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) - set -euo pipefail - SO="$(cat so_path)" - echo "checking $SO" - missing=0 - # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot - # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be - # silently dropped from the loop), and the count is asserted below so a refactor of the - # loader away from b"..." literals cannot quietly turn this whole check into a no-op that - # iterates zero times and passes. - # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would - # abort the script before the explicit ::error guard below can say WHY it failed; the - # guard on nsyms is the intended reporter for that case. - syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ - | sed 's/^b"//; s/"$//' | sort -u || true) - nsyms=$(echo "$syms" | grep -c . || true) - if [ "$nsyms" -lt 13 ]; then - echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" - missing=1 - fi - # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- - # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a - # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the - # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's - # status, so a library that HAS the symbol is reported as missing it. Measured on a real - # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both - # missing and failed the step. Note the obvious repair does NOT work -- materializing the - # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the - # producer, and it fails identically (measured). Today's release-sized .so happens to fit in - # the buffer, which is the only reason this has not fired yet. - exported="$(nm -D --defined-only "$SO")" - strs="$(strings "$SO")" - for sym in $syms; do - # Line-anchored: wrap in newlines so the pattern can require a whole line, the same - # thing `grep " T $sym$"` was expressing. - if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then - echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" - missing=1 - fi - done - # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the - # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look - # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub - # build really lacks is the dlopen target name and the import call itself. - for s in "libEGL.so.1" "eglCreateImageKHR"; do - if [[ "$strs" != *"$s"* ]]; then - echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" - missing=1 - fi - done - test "$missing" -eq 0 - echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" - - # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. - generate-bridge: - uses: ./.github/workflows/bridge.yml - - drm-deb: - name: unattended-wayland deb (verification build) - needs: generate-bridge - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Restore bridge files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bridge-artifact - path: ./ - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - # Same list the stock linux job needs, plus the flutter desktop toolchain and the three - # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). - sudo apt-get install -y \ - clang cmake curl gcc git g++ ninja-build meson pkg-config \ - libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget \ - libdrm-dev libegl1-mesa-dev libgles2-mesa-dev - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Setup flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Patch flutter - shell: bash - run: | - cd $(dirname $(dirname $(which flutter))) - # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off - # the pinned value, because the failed test becomes the script's exit status. An explicit - # if/else skips instead. Reading the values from the environment rather than interpolating - # github expressions into the script also keeps this off zizmor's template-injection list. - # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by - # actionlint and breaks workflow linting.) - if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then - git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" - else - echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" - fi - - - name: Build the unattended-wayland deb - shell: bash - run: | - set -euo pipefail - # The features have to be on the cargo line HERE, because the packaging line below passes - # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for - # the list rather than repeating it -- get_features() is the single definition of what - # these flags mean, and a hardcoded copy silently ships something other than what - # `build.py --drm` produces the moment that function changes. The flags must be the same - # on both lines for that to hold, so keep them in one variable. - DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) - FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" - echo "features from build.py: $FEATURES" - # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo - # line that builds a stock binary, which only the staged-binary marker check would catch. - # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend - # on the order get_features happens to append them (failing a correct build the day they - # are reordered) and would also match a future feature that merely contains "drm", the same - # trap build.py avoids by splitting on commas rather than testing a substring. - for want in drm drm-wake; do - case ",$FEATURES," in - *",$want,"*) ;; - *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; - esac - done - cargo build --locked --lib --release --features "$FEATURES" - python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo - - # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, - # and assert the two things that make it the drm variant at all. - - name: Assert the deb is a real drm build - shell: bash - run: | - # Strict mode so the mid-script checks can fail the step (without it only the LAST - # command's status counts and the greps above it are decorative). - set -euo pipefail - # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches - # before its own `test -n` could report, and on several matches produced a multi-line - # value whose `mv` failed with something unrelated to the real problem. - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" - # Pipe-free for the same reason as the .so contract step above (see the comment there: - # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under - # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the - # shape is the hazard and the next `-q` added here would inherit it silently. - contents="$(dpkg -c "$deb")" - if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then - echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" - exit 1 - fi - if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then - echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" - exit 1 - fi - # The library alone does not make this a drm build: build.py stages it whenever --drm is - # passed, independently of what was compiled, and the deb name is what tells a user this - # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that - # only exists when the feature is compiled in -- otherwise a stock binary could ship - # under the unattended-wayland name with a library it can never reach. - rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert - if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then - echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" - exit 1 - fi - mv "$deb" "${deb%.deb}-x86_64.deb" - - # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the - # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least - # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this - # file is not visible to whoever downloads it from the Actions UI. - - name: Measure the deb glibc floor - id: floor - shell: bash - run: | - # Strict mode for the same reason as the assert step above. The floor extraction gets an - # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a - # bare pipeline failure. - set -euo pipefail - # Same nullglob array + count assertion as the assert step above, for the same two - # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and - # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This - # was the sibling left behind when that one was fixed.) - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*-x86_64.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor - floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ - | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" - test -n "$floor" - echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" - echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" - - # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays - # out of the published release either way; the name states the floor so nobody installs it on an - # older distro and hits a bare loader error. - - name: Upload the deb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb - path: rustdesk-unattended-wayland-*-x86_64.deb diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 63526f95e..95cfdd8e3 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1749,6 +1749,276 @@ jobs: files: | res/rustdesk-${{ env.VERSION }}*.zst + # Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same + # rust and flutter -- only with the drm feature on, so it ships as the separate + # rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too + # old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and + # flatpak need that job, and a failure here must not skip them. + build-rustdesk-linux-drm: + needs: [generate-bridge] + name: build rustdesk linux drm x86_64 + runs-on: ubuntu-22.04 + steps: + - name: Export GitHub Actions cache environment variables + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Maximize build space + run: | + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/lib/android + sudo rm -rf /usr/share/dotnet + sudo apt-get update -y + sudo apt-get install -y nasm + sudo apt-get install -y qemu-user-static + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + with: + swap-size-gb: 12 + + - name: Free Space + run: | + df -h + free -m + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ env.RUST_VERSION }} + targets: x86_64-unknown-linux-gnu + components: "rustfmt" + + - name: Save Rust toolchain version + run: | + RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}') + echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV + + - name: Disable rust bridge build + run: | + # only build cdylib + sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + doNotCache: false + + - name: Install vcpkg dependencies + run: | + sudo apt install -y libva-dev && apt show libva-dev + if ! $VCPKG_ROOT/vcpkg \ + install \ + --triplet x64-linux \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do + echo "$_1:" + echo "======" + cat "$_1" + echo "======" + echo "" + done + exit 1 + fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true + shell: bash + + # The container's meson is too old to build libdrmtap, so build it here from the pin in + # build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR. + - name: Build libdrmtap + run: | + sudo apt-get install -y meson ninja-build pkg-config \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + print(f"::notice::built {b.build_libdrmtap_so()}") + PY + shell: bash + + - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26 + name: Build rustdesk + id: vcpkg + with: + arch: x86_64 + distro: ubuntu18.04 + githubToken: ${{ github.token }} + setup: | + ls -l "${PWD}" + ls -l /opt/artifacts/vcpkg/installed + dockerRunArgs: | + --volume "${PWD}:/workspace" + --volume "/opt/artifacts:/opt/artifacts" + shell: /bin/bash + install: | + apt-get update -y + echo -e "installing deps" + apt-get install -y \ + build-essential \ + clang \ + cmake \ + curl \ + gcc \ + git \ + g++ \ + libayatana-appindicator3-dev \ + libasound2-dev \ + libclang-10-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev \ + libpam0g-dev \ + libpulse-dev \ + libva-dev \ + libxcb-randr0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxdo-dev \ + libxfixes-dev \ + llvm-10-dev \ + nasm \ + ninja-build \ + pkg-config \ + tree \ + python3 \ + rpm \ + unzip \ + wget \ + xz-utils \ + libssl-dev + # we have libopus compiled by us. + apt-get remove -y libopus-dev || true + # output devs + ls -l ./ + tree -L 3 /opt/artifacts/vcpkg/installed + run: | + # disable git safe.directory + git config --global --add safe.directory "*" + # rust + pushd /opt + # do not use rustup, because memory overflow in qemu + wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz + tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz + cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh + rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu + # edit config + mkdir -p ~/.cargo/ + echo """ + [source.crates-io] + registry = 'https://github.com/rust-lang/crates.io-index' + """ > ~/.cargo/config + cat ~/.cargo/config + # start build + pushd /workspace + export VCPKG_ROOT=/opt/artifacts/vcpkg + # use the .so built on the runner; build.py checks it is the pinned checkout + export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg + # ask build.py for the features so this line and the packaging line cannot drift + FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features) + # an empty or error-shaped value would silently build a stock binary + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --features "$FEATURES" --release + rm -rf target/release/deps target/release/build + rm -rf ~/.cargo + + # Setup Flutter + # disable git safe.directory + git config --global --add safe.directory "*" + export PATH=/opt/flutter/bin:$PATH + pushd /opt + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + flutter doctor -v + + if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then + pushd /opt/flutter + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + popd + fi + + # build flutter + pushd /workspace + export CARGO_INCREMENTAL=0 + export DEB_ARCH=amd64 + python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo + for name in rustdesk*??.deb; do + mv "$name" "${name%%.deb}-x86_64.deb" + done + + # build.py can exit 0 on some inner failures, so check the artifact rather than the status. + # The package name is the informed consent for consent-free capture, so a stock binary must + # never ship under it: assert the bundled library AND the dlopen path in the binary. + - name: Check the deb is a drm build + run: | + set -euo pipefail + # Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a + # hardcoded name fails with a bare exit 1 the first time those two drift. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "DRM_DEB=$deb" >> "$GITHUB_ENV" + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::$deb has no versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::$deb has no libdrmtap.so.0 soname symlink" + exit 1 + fi + rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::$deb was not built with the drm feature" + exit 1 + fi + shell: bash + + - name: Publish debian package + if: env.UPLOAD_ARTIFACT == 'true' + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + tag_name: ${{ env.TAG_NAME }} + files: | + ${{ env.DRM_DEB }} + + # No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built. + # always(), because a deb that failed the check above is the one most worth downloading. + - name: Upload deb + if: always() && env.DRM_DEB != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ env.DRM_DEB }} + path: ${{ env.DRM_DEB }} + build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} runs-on: ${{ matrix.job.on }} diff --git a/AGENTS.md b/AGENTS.md index 8f558c959..4ff5b1e75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ * Do not make formatting-only changes. * Keep naming/style consistent with nearby code. +### Comments + +* Keep them short: one line by default, three at most. +* Say **why**, never what. If the code already says it, delete the comment. +* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR. +* A comment must never be longer than the code it describes. +* Applies to YAML, shell and Python too, not just Rust. + ### Be minimally invasive * Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none. diff --git a/build.py b/build.py index 9ebcf0eba..b32e95672 100755 --- a/build.py +++ b/build.py @@ -15,6 +15,11 @@ import argparse import sys from pathlib import Path +# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__ +# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04 +# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code. +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) + windows = platform.platform().startswith('Windows') osx = platform.platform().startswith( 'Darwin') or platform.platform().startswith("macOS") @@ -327,8 +332,8 @@ def get_features(args): # straight from `target/release` without bundling libdrmtap, without the rename, without # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput - # injection. The separate package name is the informed consent this feature rests on (see - # docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it. + # injection. The separate package name is the informed consent this feature rests on, so + # refuse rather than ship a stock-named build of it. branch = linux_packaging_branch() if branch != 'deb': raise Exception( @@ -399,20 +404,40 @@ LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED) DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1' +def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir): + # A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object, + # not an override, so it must not need the opt-in. This is how CI hands the library from a step + # that has meson to a packaging container that does not. + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') + try: + inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src + except ValueError: + return False + if not inside or not os.path.isdir(os.path.join(src, '.git')): + return False + try: + head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip() + except (subprocess.SubprocessError, OSError): + return False + return head == LIBDRMTAP_SHA + + def _validate_libdrmtap_pin(): # Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay # byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the # environment (or a malformed sha) must not be able to fail a build that never touches # libdrmtap. + # `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(), + # which tests it for truthiness. + prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None + if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt): + prebuilt = None overridden = [ name for name, value, pinned in ( ('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED), ('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED), - # `or None` so an empty value reads as unset here exactly as it does in - # build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=` - # would demand the opt-in for an override that is not going to happen. - ('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None), + ('DRMTAP_PREBUILT_DIR', prebuilt, None), ) if value != pinned ] @@ -452,7 +477,6 @@ def build_libdrmtap_so(): # library target is built (the source also carries a helper binary we do not # ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x). _validate_libdrmtap_pin() - repo_root = os.path.dirname(os.path.abspath(__file__)) # Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via # DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object). prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR') @@ -474,7 +498,7 @@ def build_libdrmtap_so(): # `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable # object. Fetching the sha needs no branch name, so it keeps working across every upstream push and # is immune to a ref being moved or repointed. - src = os.path.join(repo_root, 'third_party', 'libdrmtap') + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') if not os.path.exists(os.path.join(src, 'meson.build')): if os.path.isdir(src): shutil.rmtree(src) @@ -526,7 +550,7 @@ def _assert_so_has_egl(so_path): # EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU # stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a # perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really - # lacks. Same two markers the drm-capture workflow asserts in CI. + # lacks. try: with open(so_path, 'rb') as f: blob = f.read() @@ -566,11 +590,8 @@ def assert_so_satisfies_the_runtime_abi_gate(so_path): print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check') return so_ver = tuple(int(g) for g in m.groups()) - # Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already - # chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError - # and fails every --drm packaging run. (It did; CI caught it.) - gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') + # REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now. + gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') with open(gate_path) as f: gate_src = f.read() @@ -613,6 +634,37 @@ def stage_libdrmtap_into_deb(so_path): system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') +def _max_glibc_minor(path): + # Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because + # librustdesk.so is ~45 MB. + best = 0 + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return best + for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk): + best = max(best, int(m.group(1))) + tail = chunk[-16:] + + +def measured_glibc_floor(): + # libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged + # object is higher -- and it moves whenever either base does. + paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*') + + glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') + if os.path.isfile(p) and not os.path.islink(p)] + minor = max((_max_glibc_minor(p) for p in paths), default=0) + if not minor: + raise Exception( + f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); ' + 'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which ' + 'is what lets it install on a host where libdrmtap can never load') + return f'2.{minor}' + + def retarget_control_to_drm_variant(): # Rewrite the control file that generate_control_file just produced, instead of parameterizing that # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to @@ -620,6 +672,8 @@ def retarget_control_to_drm_variant(): # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's # own runtime deps, which the stock package has no reason to carry. path = '../res/DEBIAN/control' + floor = measured_glibc_floor() + print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}') with open(path) as f: lines = f.readlines() out = [] @@ -628,7 +682,9 @@ def retarget_control_to_drm_variant(): out.append(f'Package: {DRM_PACKAGE_NAME}\n') out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') elif line.startswith('Depends:'): - out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n') + # 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture. + out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, ' + f'libc6 (>= {floor})\n') else: out.append(line) body = ''.join(out) diff --git a/docs/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md deleted file mode 100644 index 9f0c98600..000000000 --- a/docs/DRM_CAPTURE_SECURITY.md +++ /dev/null @@ -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/-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//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..` 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 -``` diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs index f12df71f7..6df6ea61d 100644 --- a/libs/scrap/src/common/drm_render.rs +++ b/libs/scrap/src/common/drm_render.rs @@ -1,7 +1,7 @@ // Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout // dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd // in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to -// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md. +// its own CPU-mapped grab (`drmtap_grab_mapped`). use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; use super::Pixfmt; diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs index 63b46ce8b..0312c75bb 100644 --- a/libs/scrap/src/common/drmtap_dl.rs +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -196,9 +196,20 @@ impl DrmtapLib { std::iter::once(INSTALLED).chain(DEV_ONLY).collect() }; unsafe { - let (lib, name) = candidates - .iter() - .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + let mut errs = Vec::new(); + let found = candidates.iter().find_map(|n| match Library::new(*n) { + Ok(l) => Some((l, *n)), + Err(e) => { + errs.push(format!("{n}: {e}")); + None + } + }); + let Some((lib, name)) = found else { + // The dlerror names the real cause (a missing soname, a glibc too old for the + // bundled build); the caller only reports that DRM capture is off. + log::warn!("libdrmtap dlopen failed: {}", errs.join("; ")); + return None; + }; // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare // soname, while `canonicalize` resolves a relative name against it. let real = std::path::Path::new(name)