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