drm: ship the wake in the CI deb, and assert the artifact on both package paths

Three findings from the round on the wake-gate commits, all the same shape: the
gate made "what was asked for" and "what was produced" diverge, and two places
still trusted the first.

CI built the unattended-wayland deb with `--features ...,drm` and then packaged
it with `--skip-cargo`. build.py appends `drm-wake` for `--drm`, but skipping
cargo means whatever that explicit line compiled is what ships, so the deb had
no wake code in it at all while being named and documented as the variant that
has it. The feature list has to be complete on the line that actually builds.

The marker assertion that catches exactly this class only guarded one of the two
packaging paths. `build_deb_from_folder` asserts that the staged binary carries
the libdrmtap dlopen path before it takes the unattended-wayland name; the
flutter path did not, and `--skip-cargo` reaches that one. A stock binary could
therefore be packaged under a name that conflicts with and replaces the stock
package, and then never capture. Hoisted the check to module level and called it
from both, before the bundle is renamed.

And the security doc described the synthetic input injection as an unconditional
property of a drm build. It is behind its own compile feature and a runtime
option, which is exactly what an operator auditing the deb needs to know.
This commit is contained in:
Mariano Abad
2026-07-31 10:42:30 -03:00
parent ab69a612a3
commit 6088a37445
3 changed files with 51 additions and 25 deletions

View File

@@ -338,7 +338,9 @@ jobs:
- name: Build the unattended-wayland deb
shell: bash
run: |
cargo build --locked --lib --release --features hwcodec,flutter,unix-file-copy-paste,drm
# drm-wake must be listed HERE: build.py adds it for --drm, but the next line passes
# --skip-cargo, so build.py never rebuilds and whatever this line compiled is what ships.
cargo build --locked --lib --release --features hwcodec,flutter,unix-file-copy-paste,drm,drm-wake
python3 ./build.py --flutter --drm --skip-cargo
# build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status,

View File

@@ -592,6 +592,9 @@ def build_flutter_deb(version, features):
# consent-bypass variant without --drm ever being passed.
ships_so = 'drm' in features.split(',')
if ships_so:
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
assert_staged_binary_is_drm()
stage_libdrmtap_into_deb(build_libdrmtap_so())
system2('mkdir -p tmpdeb/DEBIAN')
@@ -611,6 +614,44 @@ def build_flutter_deb(version, features):
os.chdir("..")
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
def _carries_drmtap_marker(path):
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
# closes deterministically instead of relying on refcounting.
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return False
if DRMTAP_DLOPEN_MARKER in tail + chunk:
return True
tail = chunk[-(len(DRMTAP_DLOPEN_MARKER) - 1):]
def assert_staged_binary_is_drm():
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
under that name produces something that can never capture and cannot be installed alongside
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
the feature is compiled in -- assert what was produced, not what was asked for.
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
"""
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
if not any(_carries_drmtap_marker(p) for p in binaries):
raise Exception(
f'--drm was requested but the staged bundle does not look like a drm build (no '
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
'refusing to package it as the unattended-wayland variant, which conflicts with and '
'replaces the stock package but could never capture')
def build_deb_from_folder(version, binary_folder, want_drm=False):
os.chdir('flutter')
system2('mkdir -p tmpdeb/usr/bin/')
@@ -659,29 +700,7 @@ def build_deb_from_folder(version, binary_folder, want_drm=False):
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
# was produced, not what was asked for.
marker = b'/usr/lib/rustdesk/libdrmtap.so.0'
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
def _carries_marker(path):
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk
# boundary: librustdesk.so is ~45 MB and there is no reason to hold it all in memory,
# and the `with` closes deterministically instead of relying on refcounting.
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return False
if marker in tail + chunk:
return True
tail = chunk[-(len(marker) - 1):]
if not any(_carries_marker(p) for p in binaries):
raise Exception(
f'--drm was requested but the staged bundle does not look like a drm build (no '
f'{marker.decode()} dlopen path in {binaries or "any staged binary"}); refusing to '
'package it as the unattended-wayland variant, which conflicts with and replaces '
'the stock package but could never capture')
assert_staged_binary_is_drm()
if bundle_carries_so:
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
# The THIRD artifact source, and the last one that was missing the check: --package

View File

@@ -142,7 +142,12 @@ presents) but reuses RustDesk's own hardened IPC.
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.** A
- **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