#[path = "ipc/auth.rs"] mod ipc_auth; #[cfg(any(target_os = "linux", target_os = "macos"))] #[path = "ipc/fs.rs"] mod ipc_fs; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::plugin::ipc::Plugin; use crate::{ common::{is_server, CheckTestNatType}, privacy_mode, privacy_mode::PrivacyModeState, rendezvous_mediator::RendezvousMediator, ui_interface::{get_local_option, set_local_option}, }; use bytes::Bytes; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use clipboard::ClipboardFile; #[cfg(target_os = "linux")] use hbb_common::anyhow; use hbb_common::{ allow_err, bail, bytes, bytes_codec::BytesCodec, config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2}, futures::StreamExt as _, futures_util::sink::SinkExt, log, password_security as password, timeout, tokio::{ self, io::{AsyncRead, AsyncWrite}, }, tokio_util::codec::Framed, ResultType, }; #[cfg(windows)] pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; #[cfg(windows)] pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; #[cfg(windows)] pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; #[cfg(target_os = "macos")] use ipc_auth::authorize_user_server_process; #[cfg(all(target_os = "linux", feature = "drm"))] use ipc_auth::active_uid_cached; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, should_allow_everyone_create_on_windows, }; #[cfg(target_os = "linux")] pub(crate) use ipc_auth::{ ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, log_rejected_uinput_connection, peer_uid_from_fd, }; #[cfg(target_os = "linux")] use ipc_fs::terminal_count_candidate_uids; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_fs::{ check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, should_scrub_parent_entries_after_check_pid, write_pid, }; use parity_tokio_ipc::{ Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, }; use serde_derive::{Deserialize, Serialize}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::cell::Cell; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::os::unix::fs::PermissionsExt; #[cfg(all(target_os = "linux", feature = "drm"))] use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; use std::{ collections::HashMap, sync::atomic::{AtomicBool, Ordering}, }; // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; #[cfg(target_os = "windows")] const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; #[cfg(target_os = "windows")] pub(crate) const IPC_TOKEN_LEN: usize = 64; #[cfg(target_os = "windows")] const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; #[cfg(target_os = "windows")] const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); #[cfg(any(target_os = "linux", target_os = "macos"))] thread_local! { static USE_USER_MAIN_IPC: Cell = Cell::new(false); } #[must_use = "bind this guard to a local variable to keep the IPC scope active"] /// Thread-local guard for routing root main IPC to the active user on Linux/macOS. #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) struct UserMainIpcScope { previous: bool, } #[cfg(any(target_os = "linux", target_os = "macos"))] impl UserMainIpcScope { pub(crate) fn new() -> Self { let previous = USE_USER_MAIN_IPC.with(|use_user_main| { let previous = use_user_main.get(); use_user_main.set(true); previous }); Self { previous } } } #[cfg(any(target_os = "linux", target_os = "macos"))] impl Drop for UserMainIpcScope { fn drop(&mut self) { USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.set(self.previous)); } } #[inline] pub async fn connect_service(ms_timeout: u64) -> ResultType> { connect(ms_timeout, crate::POSTFIX_SERVICE).await } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum FS { ReadEmptyDirs { dir: String, include_hidden: bool, }, ReadDir { dir: String, include_hidden: bool, }, RemoveDir { path: String, id: i32, recursive: bool, }, RemoveFile { path: String, id: i32, file_num: i32, }, CreateDir { path: String, id: i32, }, NewWrite { path: String, id: i32, file_num: i32, files: Vec<(String, u64)>, overwrite_detection: bool, total_size: u64, conn_id: i32, }, CancelWrite { id: i32, }, WriteBlock { id: i32, file_num: i32, data: Bytes, compressed: bool, }, WriteDone { id: i32, file_num: i32, }, WriteError { id: i32, file_num: i32, err: String, }, WriteOffset { id: i32, file_num: i32, offset_blk: u32, }, CheckDigest { id: i32, file_num: i32, file_size: u64, last_modified: u64, is_upload: bool, is_resume: bool, }, SendConfirm(Vec), Rename { id: i32, path: String, new_name: String, }, // CM-side file reading operations (Windows only) // These enable Connection Manager to read files and stream them back to Connection ReadFile { path: String, id: i32, file_num: i32, include_hidden: bool, conn_id: i32, overwrite_detection: bool, }, CancelRead { id: i32, conn_id: i32, }, SendConfirmForRead { id: i32, file_num: i32, skip: bool, offset_blk: u32, conn_id: i32, }, ReadAllFiles { path: String, id: i32, include_hidden: bool, conn_id: i32, }, } #[cfg(target_os = "windows")] #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t")] pub struct ClipboardNonFile { pub compress: bool, pub content: bytes::Bytes, pub content_len: usize, pub next_raw: bool, pub width: i32, pub height: i32, // message.proto: ClipboardFormat pub format: i32, pub special_name: String, } #[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum DataKeyboard { Sequence(String), KeyDown(enigo::Key), KeyUp(enigo::Key), KeyClick(enigo::Key), GetKeyState(enigo::Key), } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum DataKeyboardResponse { GetKeyState(bool), } #[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum DataMouse { MoveTo(i32, i32), MoveRelative(i32, i32), Down(enigo::MouseButton), Up(enigo::MouseButton), Click(enigo::MouseButton), ScrollX(i32), ScrollY(i32), Refresh, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum DataControl { Resolution { minx: i32, maxx: i32, miny: i32, maxy: i32, }, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum DataPortableService { Ping, Pong, AuthToken(String), AuthResult(bool), ConnCount(Option), Mouse((Vec, i32, String, u32, bool, bool)), Pointer((Vec, i32)), Key(Vec), RequestStart, WillClose, CmShowElevation(bool), } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum Data { Login { id: i32, is_file_transfer: bool, is_view_camera: bool, is_terminal: bool, peer_id: String, name: String, avatar: String, authorized: bool, port_forward: String, keyboard: bool, clipboard: bool, audio: bool, file: bool, file_transfer_enabled: bool, restart: bool, recording: bool, block_input: bool, privacy_mode: bool, from_switch: bool, }, ChatMessage { text: String, }, SwitchPermission { name: String, enabled: bool, }, SystemInfo(Option), ClickTime(i64), #[cfg(not(any(target_os = "android", target_os = "ios")))] MouseMoveTime(i64), Authorize, Close, #[cfg(windows)] SAS, UserSid(Option), OnlineStatus(Option<(i64, bool)>), Config((String, Option)), Options(Option>), NatType(Option), ConfirmedKey(Option<(Vec, Vec)>), RawMessage(Vec), Socks(Option), FS(FS), Test, SyncConfig(Option>), #[cfg(target_os = "windows")] ClipboardFile(ClipboardFile), ClipboardFileEnabled(bool), #[cfg(target_os = "windows")] ClipboardNonFile(Option<(String, Vec)>), PrivacyModeState((i32, PrivacyModeState, String)), TestRendezvousServer, Deployed, #[cfg(not(any(target_os = "android", target_os = "ios")))] Keyboard(DataKeyboard), #[cfg(not(any(target_os = "android", target_os = "ios")))] KeyboardResponse(DataKeyboardResponse), #[cfg(not(any(target_os = "android", target_os = "ios")))] Mouse(DataMouse), Control(DataControl), Theme(String), Language(String), Empty, Disconnected, DataPortableService(DataPortableService), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesRequest(String), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesUuid(String, String, Option), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, UrlLink(String), VoiceCallIncoming, StartVoiceCall, VoiceCallResponse(bool), CloseVoiceCall(String), #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] Plugin(Plugin), #[cfg(windows)] SyncWinCpuUsage(Option), FileTransferLog((String, String)), #[cfg(windows)] ControlledSessionCount(usize), CmErr(String), // CM-side file reading responses (Windows only) // These are sent from CM back to Connection when CM handles file reading /// Response to ReadFile: contains initial file list or error ReadJobInitResult { id: i32, file_num: i32, include_hidden: bool, conn_id: i32, /// Serialized protobuf bytes of FileDirectory, or error string result: Result, String>, }, /// File data block read by CM. /// /// The actual data is sent separately via `send_raw()` after this message to avoid /// JSON encoding overhead for large binary data. This mirrors the `WriteBlock` pattern. /// /// **Protocol:** /// - Sender: `send(FileBlockFromCM{...})` then `send_raw(data)` /// - Receiver: `next()` returns `FileBlockFromCM`, then `next_raw()` returns data bytes /// /// **Note on empty data (e.g., empty files):** /// Empty data is supported. The IPC connection uses `BytesCodec` with `raw=false` (default), /// which prefixes each frame with a length header. So `send_raw(Bytes::new())` sends a /// 1-byte frame (length=0), and `next_raw()` correctly returns an empty `BytesMut`. /// See `libs/hbb_common/src/bytes_codec.rs` test `test_codec2` for verification. FileBlockFromCM { id: i32, file_num: i32, /// Data is sent separately via `send_raw()` to avoid JSON encoding overhead. /// This field is skipped during serialization; sender must call `send_raw()` after sending. /// Receiver must call `next_raw()` and populate this field manually. #[serde(skip)] data: bytes::Bytes, compressed: bool, conn_id: i32, }, /// File read completed successfully FileReadDone { id: i32, file_num: i32, conn_id: i32, }, /// File read failed with error FileReadError { id: i32, file_num: i32, err: String, conn_id: i32, }, /// Digest info from CM for overwrite detection FileDigestFromCM { id: i32, file_num: i32, last_modified: u64, file_size: u64, is_resume: bool, conn_id: i32, }, /// Response to ReadAllFiles: recursive directory listing AllFilesResult { id: i32, conn_id: i32, path: String, /// Serialized protobuf bytes of FileDirectory, or error string result: Result, String>, }, CheckHwcodec, #[cfg(feature = "flutter")] VideoConnCount(Option), // Although the key is not necessary, it is used to avoid hardcoding the key. WaylandScreencastRestoreToken((String, String)), HwCodecConfig(Option), RemoveTrustedDevices(Vec), ClearTrustedDevices, #[cfg(all(target_os = "windows", feature = "flutter"))] PrinterData(Vec), InstallOption(Option<(String, String)>), #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) ))] ControllingSessionCount(usize), #[cfg(target_os = "linux")] TerminalSessionCount(usize), #[cfg(target_os = "windows")] PortForwardSessionCount(Option), SocksWs(Option, String)>>), #[cfg(target_os = "macos")] HasNoActiveConns(Option), #[cfg(not(any(target_os = "android", target_os = "ios")))] Whiteboard((String, crate::whiteboard::CustomEvent)), ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the // client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and // `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly // one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps // the header extensible. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a // small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on // the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body. /// Client -> service: begin streaming the chosen display. #[cfg(all(target_os = "linux", feature = "drm"))] // `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context // (drmtap_open_render failed, or an old .so lacks the split symbols). The service then streams the // CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot // detile, so a render-node-less seat still captures instead of losing the stream. DrmStart { display: i32, need_cpu: bool }, /// Service -> client: the enumerated DRM displays (sent once, before frames). #[cfg(all(target_os = "linux", feature = "drm"))] DrmDisplayList(Vec), /// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset, /// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the /// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing /// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same /// stream; carries no `send_raw()` body and no fd. #[cfg(all(target_os = "linux", feature = "drm"))] DrmDisplaysChanged(Vec), /// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`. /// CPU-fallback path (old .so, no render node): pixels cross the wire. #[cfg(all(target_os = "linux", feature = "drm"))] DrmFrame { width: u32, height: u32 }, /// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when /// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and /// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does /// the EGL detile/convert itself (see `DmabufDesc`). #[cfg(all(target_os = "linux", feature = "drm"))] DrmFrameDmabuf(DmabufDesc), /// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`. #[cfg(all(target_os = "linux", feature = "drm"))] DrmCursor { id: u64, width: u32, height: u32, hotx: i32, hoty: i32, }, } /// One enumerated DRM display shipped over `_drm` (physical geometry). The serializable IPC /// form of `scrap::drm_reader::DisplaySnapshot`; the server augments it with the Wayland /// logical geometry/scale, which needs the user session. #[cfg(all(target_os = "linux", feature = "drm"))] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DrmDisplayInfo { pub name: String, pub crtc_id: u32, pub x: i32, pub y: i32, pub width: u32, pub height: u32, pub active: bool, } /// Serializable metadata descriptor of a scanout dma-buf, shipped over `_drm` as the JSON payload of /// `Data::DrmFrameDmabuf`. It mirrors `scrap::drm_reader::drmtap_dmabuf_desc` field-for-field EXCEPT /// the process-local `dma_buf_fd` (which never serializes — it rides SCM_RIGHTS ancillary), and adds /// `buffer_id` (the producer's stable pool key) and `has_fd` (whether this message's `send_msg` /// carries the fd, vs an import-once cache hit that omits it). The converter rebuilds a /// `drmtap_dmabuf_desc` from these fields and overwrites its `dma_buf_fd` with the received fd. #[cfg(all(target_os = "linux", feature = "drm"))] #[derive(Debug, Serialize, Deserialize, Clone)] pub struct DmabufDesc { /// Producer-side stable pool key (e.g. fb_id + a connection epoch). Distinct from `fb_id`, which /// is libdrmtap's import-once cache key. pub buffer_id: u64, pub width: u32, pub height: u32, /// DRM fourcc of the scanout. pub format: u32, /// DRM format modifier (tiling/compression). pub modifier: u64, /// KMS framebuffer id — libdrmtap's import-once cache key. 0 disables caching for this frame. pub fb_id: u32, /// Used entries in `offsets`/`pitches` (1..4); 0 is treated as 1. pub num_planes: u32, /// Per-plane byte offsets into the dma-buf (CCS main + aux + clear-color). pub offsets: [u32; 4], /// Per-plane strides in bytes; `pitches[0]` is the main-surface stride. pub pitches: [u32; 4], /// DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3). PQ triggers the HDR->SDR tone-map on convert. pub hdr_eotf: u32, /// Content/mastering peak luminance (cd/m2); 0 = unknown. pub hdr_max_nits: u32, /// True: this message's `send_msg` attaches the dma-buf fd in an SCM_RIGHTS cmsg. False: an /// import-once cache hit for `fb_id` — no fd attached, converter reuses its cached EGLImage. pub has_fd: bool, } #[tokio::main(flavor = "current_thread")] pub async fn start(postfix: &str) -> ResultType<()> { let mut incoming = new_listener(postfix).await?; loop { if let Some(result) = incoming.next().await { match result { Ok(stream) => { let mut stream = Connection::new(stream); let postfix = postfix.to_owned(); #[cfg(any(target_os = "linux", target_os = "macos"))] if config::is_service_ipc_postfix(&postfix) { if !authorize_service_scoped_ipc_connection(&stream, &postfix) { continue; } } #[cfg(windows)] if postfix.is_empty() { // Windows main IPC (`postfix == ""`) is authorized here. // Other security-sensitive channels use dedicated authorization paths: // - `_portable_service`: portable-service listener + handshake policy // - service-scoped postfixes: service-specific listener/authorization if !authorize_windows_main_ipc_connection(&stream, &postfix) { continue; } } tokio::spawn(async move { loop { match stream.next().await { Err(err) => { log::trace!("ipc '{}' connection closed: {}", postfix, err); break; } Ok(Some(data)) => { // On Linux/macOS, the protected `_service` channel is used only for // syncing config between root service and the active user process. // // NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those // channels are handled by the dedicated uinput listener/protocol in // `src/server/uinput.rs` and therefore do not share this Data enum // allowlist. The SyncConfig allowlist here is intentionally scoped to the // `_service` channel only. // // Keep this explicit branch to avoid policy drift between `_service` and // uinput IPC paths while still minimizing exposed message surface here. #[cfg(any(target_os = "linux", target_os = "macos"))] if postfix == crate::POSTFIX_SERVICE { if matches!(&data, Data::SyncConfig(_)) { handle(data, &mut stream).await; } else { log::warn!( "Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}", postfix, std::mem::discriminant(&data), stream.peer_uid() ); // Close the connection to avoid keeping a protected channel // alive while repeatedly receiving invalid traffic. break; } continue; } handle(data, &mut stream).await; } Ok(None) => { // `Ok(None)` means a complete frame arrived but did not // deserialize into `Data`. Peer close/reset is returned as // `Err` by `ConnectionTmpl::next()`. Keep the historical // ignore behavior except on the protected `_service` channel. #[cfg(any(target_os = "linux", target_os = "macos"))] { if postfix == crate::POSTFIX_SERVICE { break; } } } } } }); } Err(err) => { log::error!("Couldn't get client: {:?}", err); } } } } } pub async fn new_listener(postfix: &str) -> ResultType { let path = Config::ipc_path(postfix); #[cfg(any(target_os = "linux", target_os = "macos"))] let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?; #[cfg(any(target_os = "linux", target_os = "macos"))] let existing_listener_alive = check_pid(postfix).await; #[cfg(any(target_os = "linux", target_os = "macos"))] if should_scrub_parent_entries_after_check_pid( should_scrub_parent_entries, existing_listener_alive, ) { scrub_secure_ipc_parent_dir(&path, postfix)?; } let mut endpoint = Endpoint::new(path.clone()); let security_attrs = { #[cfg(windows)] { if postfix == "_portable_service" { portable_service_listener_security_attributes() } else if should_allow_everyone_create_on_windows(postfix) { SecurityAttributes::allow_everyone_create() } else { Ok(SecurityAttributes::empty()) } } #[cfg(not(windows))] { SecurityAttributes::allow_everyone_create() } }; match security_attrs { Ok(attr) => endpoint.set_security_attributes(attr), Err(err) => { log::error!("Failed to set ipc{} security: {}", postfix, err); #[cfg(windows)] if postfix == "_portable_service" { // Fail closed for `_portable_service` when SDDL construction fails. // This endpoint is security-critical and must not start with default ACLs. return Err(err.into()); } } }; match endpoint.incoming() { Ok(incoming) => { if postfix == crate::POSTFIX_SERVICE { log::info!("Started protected ipc service server: postfix={}", postfix); } else { log::info!("Started ipc{} server at path: {}", postfix, &path); } #[cfg(any(target_os = "linux", target_os = "macos"))] { // NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable // (0666) so the active (non-root) user process can connect. Authorization is // enforced at accept-time for these channels, and the protected `_service` // channel is further restricted by an explicit message allowlist (SyncConfig // only). let socket_mode = if config::is_service_ipc_postfix(postfix) { 0o0666 } else { 0o0600 }; if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode)) { log::error!( "Failed to set permissions on ipc{} socket at path {}: {}", postfix, &path, err ); std::fs::remove_file(&path).ok(); return Err(err.into()); } write_pid(postfix); } Ok(incoming) } Err(err) => { log::error!( "Failed to start ipc{} server at path {}: {}", postfix, path, err ); Err(err.into()) } } } pub struct CheckIfRestart { stop_service: String, rendezvous_servers: Vec, audio_input: String, voice_call_input: String, ws: String, disable_udp: String, allow_insecure_tls_fallback: String, api_server: String, } impl CheckIfRestart { pub fn new() -> CheckIfRestart { CheckIfRestart { stop_service: Config::get_option("stop-service"), rendezvous_servers: Config::get_rendezvous_servers(), audio_input: Config::get_option("audio-input"), voice_call_input: Config::get_option("voice-call-input"), ws: Config::get_option(OPTION_ALLOW_WEBSOCKET), disable_udp: Config::get_option(config::keys::OPTION_DISABLE_UDP), allow_insecure_tls_fallback: Config::get_option( config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK, ), api_server: Config::get_option("api-server"), } } } impl Drop for CheckIfRestart { fn drop(&mut self) { // If https proxy is used, we need to restart rendezvous mediator. // No need to check if https proxy is used, because this option does not change frequently // and restarting mediator is safe even https proxy is not used. let allow_insecure_tls_fallback_changed = self.allow_insecure_tls_fallback != Config::get_option(config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK); if allow_insecure_tls_fallback_changed || self.stop_service != Config::get_option("stop-service") || self.rendezvous_servers != Config::get_rendezvous_servers() || self.ws != Config::get_option(OPTION_ALLOW_WEBSOCKET) || self.disable_udp != Config::get_option(config::keys::OPTION_DISABLE_UDP) || self.api_server != Config::get_option("api-server") { if allow_insecure_tls_fallback_changed { hbb_common::tls::reset_tls_cache(); } RendezvousMediator::restart(); } if self.audio_input != Config::get_option("audio-input") { crate::audio_service::restart(); } if self.voice_call_input != Config::get_option("voice-call-input") { crate::audio_service::set_voice_call_input_device( Some(Config::get_option("voice-call-input")), true, ) } } } async fn handle(data: Data, stream: &mut Connection) { match data { Data::SystemInfo(_) => { let info = format!( "log_path: {}, config: {}, username: {}", Config::log_path().to_str().unwrap_or(""), Config::file().to_str().unwrap_or(""), crate::username(), ); allow_err!(stream.send(&Data::SystemInfo(Some(info))).await); } Data::ClickTime(_) => { let t = crate::server::CLICK_TIME.load(Ordering::SeqCst); allow_err!(stream.send(&Data::ClickTime(t)).await); } #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::MouseMoveTime(_) => { let t = crate::server::MOUSE_MOVE_TIME.load(Ordering::SeqCst); allow_err!(stream.send(&Data::MouseMoveTime(t)).await); } Data::Close => { log::info!("Receive close message"); if EXIT_RECV_CLOSE.load(Ordering::SeqCst) { #[cfg(not(target_os = "android"))] crate::server::input_service::fix_key_down_timeout_at_exit(); if is_server() { let _ = privacy_mode::turn_off_privacy(0, Some(PrivacyModeState::OffByPeer)); } #[cfg(any(target_os = "macos", target_os = "linux"))] if crate::is_main() { // below part is for main windows can be reopen during rustdesk installation and installing service from UI // this make new ipc server (domain socket) can be created. std::fs::remove_file(&Config::ipc_path("")).ok(); #[cfg(target_os = "linux")] { hbb_common::sleep((crate::platform::SERVICE_INTERVAL * 2) as f32 / 1000.0) .await; // https://github.com/rustdesk/rustdesk/discussions/9254 crate::run_me::<&str>(vec!["--no-server"]).ok(); } #[cfg(target_os = "macos")] { // our launchagent interval is 1 second hbb_common::sleep(1.5).await; std::process::Command::new("open") .arg("-n") .arg(&format!("/Applications/{}.app", crate::get_app_name())) .spawn() .ok(); } // leave above open a little time hbb_common::sleep(0.3).await; // in case below exit failed crate::platform::quit_gui(); } std::process::exit(-1); // to make sure --server luauchagent process can restart because SuccessfulExit used } } Data::OnlineStatus(_) => { let x = config::get_online_state(); let confirmed = Config::get_key_confirmed(); allow_err!(stream.send(&Data::OnlineStatus(Some((x, confirmed)))).await); } Data::ConfirmedKey(None) => { let out = if Config::get_key_confirmed() { Some(Config::get_key_pair()) } else { None }; allow_err!(stream.send(&Data::ConfirmedKey(out)).await); } Data::Socks(s) => match s { None => { allow_err!(stream.send(&Data::Socks(Config::get_socks())).await); } Some(data) => { let _nat = CheckTestNatType::new(); if data.proxy.is_empty() { Config::set_socks(None); } else { Config::set_socks(Some(data)); } RendezvousMediator::restart(); log::info!("socks updated"); } }, Data::SocksWs(s) => match s { None => { allow_err!( stream .send(&Data::SocksWs(Some(Box::new(( Config::get_socks(), Config::get_option(OPTION_ALLOW_WEBSOCKET) ))))) .await ); } _ => {} }, #[cfg(feature = "flutter")] Data::VideoConnCount(None) => { let n = crate::server::AUTHED_CONNS .lock() .unwrap() .iter() .filter(|x| x.conn_type == crate::server::AuthConnType::Remote) .count(); allow_err!(stream.send(&Data::VideoConnCount(Some(n))).await); } Data::Config((name, value)) => match value { None => { let value; if name == "id" { value = Some(Config::get_id()); } else if name == "temporary-password" { value = Some(password::temporary_password()); } else if name == "permanent-password-storage-and-salt" { let (storage, salt) = Config::get_local_permanent_password_storage_and_salt(); value = Some(storage + "\n" + &salt); } else if name == "permanent-password-set" { value = Some(if Config::has_permanent_password() { "Y".to_owned() } else { "N".to_owned() }); } else if name == "permanent-password-is-preset" { value = Some(if Config::is_using_preset_password() { "Y".to_owned() } else { "N".to_owned() }); } else if name == "salt" { value = Some(Config::get_salt()); } else if name == "rendezvous_server" { value = Some(format!( "{},{}", Config::get_rendezvous_server(), Config::get_rendezvous_servers().join(",") )); } else if name == "rendezvous_servers" { value = Some(Config::get_rendezvous_servers().join(",")); } else if name == "fingerprint" { value = if Config::get_key_confirmed() { Some(crate::common::pk_to_fingerprint(Config::get_key_pair().1)) } else { None }; } else if name == "hide_cm" { value = if crate::hbbs_http::sync::is_pro() || crate::common::is_custom_client() { Some(hbb_common::password_security::hide_cm().to_string()) } else { None }; } else if name == "voice-call-input" { value = crate::audio_service::get_voice_call_input_device(); } else if name == "unlock-pin" { value = Some(Config::get_unlock_pin()); } else if name == "trusted-devices" { value = Some(Config::get_trusted_devices_json()); } else { value = None; } allow_err!(stream.send(&Data::Config((name, value))).await); } Some(value) => { let mut updated = true; if name == "id" { // An empty id would wipe the local id and unconfirm the key (cf. #15626). if value.is_empty() { log::warn!("Ignoring empty id write over IPC"); updated = false; } else { Config::set_key_confirmed(false); Config::set_id(&value); } } else if name == "temporary-password" { password::update_temporary_password(); } else if name == "permanent-password" { if Config::is_disable_change_permanent_password() { log::warn!("Changing permanent password is disabled"); updated = false; } else { updated = Config::set_permanent_password(&value); } // Explicitly ACK/NACK permanent-password writes. This allows UIs/FFI to // distinguish "accepted by daemon" vs "IPC send succeeded" without // reading back any secret. let ack = if updated { "Y" } else { "N" }.to_owned(); allow_err!(stream.send(&Data::Config((name.clone(), Some(ack)))).await); } else if name == "salt" { Config::set_salt(&value); } else if name == "voice-call-input" { crate::audio_service::set_voice_call_input_device(Some(value), true); } else if name == "unlock-pin" { Config::set_unlock_pin(&value); } else { return; } if updated { log::info!("{} updated", name); } } }, Data::Options(value) => match value { None => { let v = Config::get_options(); allow_err!(stream.send(&Data::Options(Some(v))).await); } Some(value) => { let _chk = CheckIfRestart::new(); let _nat = CheckTestNatType::new(); if let Some(v) = value.get("privacy-mode-impl-key") { crate::privacy_mode::switch(v); } Config::set_options(value); allow_err!(stream.send(&Data::Options(None)).await); } }, Data::NatType(_) => { let t = Config::get_nat_type(); allow_err!(stream.send(&Data::NatType(Some(t))).await); } Data::SyncConfig(Some(configs)) => { let (config, config2) = *configs; let _chk = CheckIfRestart::new(); Config::set(config); Config2::set(config2); allow_err!(stream.send(&Data::SyncConfig(None)).await); } Data::SyncConfig(None) => { allow_err!( stream .send(&Data::SyncConfig(Some( (Config::get(), Config2::get()).into() ))) .await ); } #[cfg(windows)] Data::SyncWinCpuUsage(None) => { allow_err!( stream .send(&Data::SyncWinCpuUsage( hbb_common::platform::windows::cpu_uage_one_minute() )) .await ); } Data::TestRendezvousServer => { crate::test_rendezvous_server(); } Data::Deployed => { crate::rendezvous_mediator::NEEDS_DEPLOY.store(false, Ordering::SeqCst); crate::rendezvous_mediator::RendezvousMediator::restart(); } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); allow_err!( stream .send(&Data::SwitchSidesRequest(uuid.to_string())) .await ); } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesUuid(uuid, id, None) => { let allowed = uuid .parse::() .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) .unwrap_or(false); allow_err!( stream .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) .await ); } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, #[cfg(windows)] Data::ControlledSessionCount(_) => { allow_err!( stream .send(&Data::ControlledSessionCount( crate::Connection::alive_conns().len() )) .await ); } #[cfg(target_os = "macos")] Data::HasNoActiveConns(None) => { allow_err!( stream .send(&Data::HasNoActiveConns(Some( crate::updater::has_no_active_conns() ))) .await ); } #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) ))] Data::ControllingSessionCount(count) => { crate::updater::update_controlling_session_count(count); } #[cfg(target_os = "linux")] Data::TerminalSessionCount(_) => { let count = crate::terminal_service::get_terminal_session_count(true); allow_err!(stream.send(&Data::TerminalSessionCount(count)).await); } #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::CheckHwcodec => { scrap::hwcodec::start_check_process(); } #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::HwCodecConfig(c) => { match c { None => { let v = match scrap::hwcodec::HwCodecConfig::get_set_value() { Some(v) => Some(serde_json::to_string(&v).unwrap_or_default()), None => None, }; allow_err!(stream.send(&Data::HwCodecConfig(v)).await); } Some(v) => { // --server and portable scrap::hwcodec::HwCodecConfig::set(v); } } } Data::WaylandScreencastRestoreToken((key, value)) => { let v = if value == "get" { let opt = get_local_option(key.clone()); #[cfg(not(target_os = "linux"))] { Some(opt) } #[cfg(target_os = "linux")] { let v = if opt.is_empty() { if scrap::wayland::pipewire::is_rdp_session_hold() { "fake token".to_string() } else { "".to_owned() } } else { opt }; Some(v) } } else if value == "clear" { set_local_option(key.clone(), "".to_owned()); #[cfg(target_os = "linux")] scrap::wayland::pipewire::close_session(); Some("".to_owned()) } else { None }; if let Some(v) = v { allow_err!( stream .send(&Data::WaylandScreencastRestoreToken((key, v))) .await ); } } Data::RemoveTrustedDevices(v) => { Config::remove_trusted_devices(&v); } Data::ClearTrustedDevices => { Config::clear_trusted_devices(); } Data::InstallOption(opt) => match opt { Some((_k, _v)) => { #[cfg(target_os = "windows")] if let Err(e) = crate::platform::windows::update_install_option(&_k, &_v) { log::error!( "Failed to update install option \"{}\" to \"{}\", error: {}", &_k, &_v, e ); } } None => { // `None` is usually used to get values. // This branch is left blank for unification and further use. } }, #[cfg(target_os = "windows")] Data::PortForwardSessionCount(c) => match c { None => { let count = crate::server::AUTHED_CONNS .lock() .unwrap() .iter() .filter(|c| c.conn_type == crate::server::AuthConnType::PortForward) .count(); allow_err!( stream .send(&Data::PortForwardSessionCount(Some(count))) .await ); } _ => { // Port forward session count is only a get value. } }, Data::ControlPermissionsRemoteModify(_) => { use hbb_common::rendezvous_proto::control_permissions::Permission; let state = crate::server::get_control_permission_state(Permission::remote_modify, true); allow_err!( stream .send(&Data::ControlPermissionsRemoteModify(state)) .await ); } #[cfg(target_os = "windows")] Data::FileTransferEnabledState(_) => { use hbb_common::rendezvous_proto::control_permissions::Permission; let state = crate::server::get_control_permission_state(Permission::file, false); let enabled = state.unwrap_or_else(|| { crate::server::Connection::is_permission_enabled_locally( config::keys::OPTION_ENABLE_FILE_TRANSFER, ) }); allow_err!( stream .send(&Data::FileTransferEnabledState(Some(enabled))) .await ); } _ => {} }; } #[cfg(target_os = "windows")] pub(crate) fn generate_one_time_ipc_token() -> ResultType { use hbb_common::rand::{rngs::OsRng, RngCore as _}; use std::fmt::Write as _; let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES]; let mut rng = OsRng; rng.try_fill_bytes(&mut random_bytes).map_err(|err| { hbb_common::anyhow::anyhow!( "failed to generate portable service ipc token from OsRng: {}", err ) })?; let mut token = String::with_capacity(IPC_TOKEN_LEN); for byte in random_bytes { let _ = write!(token, "{:02x}", byte); } Ok(token) } #[cfg(target_os = "windows")] pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { return false; } expected .as_bytes() .iter() .zip(candidate.as_bytes().iter()) .fold(0u8, |diff, (left, right)| diff | (*left ^ *right)) == 0 } #[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_client( stream: &mut ConnectionTmpl, token: &str, ) -> ResultType<()> where T: AsyncRead + AsyncWrite + std::marker::Unpin, { stream .send(&Data::DataPortableService(DataPortableService::AuthToken( token.to_owned(), ))) .await?; match stream .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) .await? { Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()), Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => { bail!("portable service ipc handshake was rejected by server") } Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"), } } #[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_server( stream: &mut ConnectionTmpl, mut validate_token: F, ) -> ResultType<()> where T: AsyncRead + AsyncWrite + std::marker::Unpin, // Token validators must use `constant_time_ipc_token_eq` or an equivalent // fixed-length comparison; this handshake is part of the privilege boundary. F: FnMut(&str) -> bool, { let authorized = match stream .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) .await? { Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => { validate_token(&token) } Some(_) | None => false, }; stream .send(&Data::DataPortableService(DataPortableService::AuthResult( authorized, ))) .await?; if !authorized { bail!("portable service ipc handshake failed") } Ok(()) } #[inline] async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType> { let client = timeout(ms_timeout, Endpoint::connect(path)).await??; Ok(ConnectionTmpl::new(client)) } #[cfg(any(target_os = "linux", target_os = "macos"))] #[inline] fn select_server_uid_for_user_main_ipc( server_uids: &[u32], active_uid: Option, prefer_root: bool, ) -> ResultType { let mut server_uids = server_uids.to_vec(); server_uids.sort_unstable(); server_uids.dedup(); match server_uids.as_slice() { [] => { if let Some(uid) = active_uid { // If no `--server` processes are found but the active user is identifiable, // try the active user anyway because the main process may also listen on "" IPC. return Ok(uid); } else { bail!("No --server process found for user main IPC") } } [uid] => return Ok(*uid), _ => {} } if prefer_root && server_uids.contains(&0) { return Ok(0); } if let Some(active_uid) = active_uid.filter(|uid| server_uids.contains(uid)) { return Ok(active_uid); } bail!("Multiple --server processes found for user main IPC"); } #[cfg(any(target_os = "linux", target_os = "macos"))] fn running_server_uids_for_current_exe() -> ResultType> { let current_exe = std::env::current_exe()?; let current_exe_path = std::fs::canonicalize(¤t_exe)?; let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id()); let mut sys = hbb_common::sysinfo::System::new(); sys.refresh_processes(); let mut server_uids = Vec::new(); for process in sys.processes().values() { if process.pid() == current_pid { continue; } if process.cmd().get(1).map_or(true, |arg| arg != "--server") { continue; } let Ok(process_path) = std::fs::canonicalize(process.exe()) else { continue; }; if process_path != current_exe_path { continue; } let Some(uid) = process.user_id().map(|uid| **uid as u32) else { // Root CLI management commands need a stable matching `--server` target. // If this key process races during enumeration, failing the command is clearer // than silently skipping it; `--server` is not expected to exit frequently. bail!("Failed to read --server process uid"); }; server_uids.push(uid); } Ok(server_uids) } #[cfg(any(target_os = "linux", target_os = "macos"))] fn user_main_ipc_server_uid() -> ResultType { let server_uids = running_server_uids_for_current_exe()?; #[cfg(target_os = "linux")] let prefer_root = crate::platform::linux::is_login_screen_wayland(); #[cfg(target_os = "macos")] let prefer_root = false; select_server_uid_for_user_main_ipc(&server_uids, active_uid(), prefer_root) } pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { #[cfg(any(target_os = "linux", target_os = "macos"))] { let use_user_main_ipc = USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.get()); let is_root_main_ipc = unsafe { hbb_common::libc::geteuid() == 0 } && postfix.is_empty() && use_user_main_ipc; if is_root_main_ipc { let uid = user_main_ipc_server_uid()?; let path = Config::ipc_path_for_uid(uid, postfix); return connect_with_path(ms_timeout, &path).await; } let path = Config::ipc_path(postfix); return connect_with_path(ms_timeout, &path).await; } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let path = Config::ipc_path(postfix); connect_with_path(ms_timeout, &path).await } } #[cfg(any(target_os = "linux", target_os = "macos"))] pub async fn connect_for_uid( ms_timeout: u64, uid: u32, postfix: &str, ) -> ResultType> { let path = Config::ipc_path_for_uid(uid, postfix); let conn = connect_with_path(ms_timeout, &path).await?; #[cfg(target_os = "macos")] if postfix.is_empty() && !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid) { bail!("Rejected user IPC peer for uid {}", uid); } Ok(conn) } #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn start_pa() { use crate::audio_service::AUDIO_DATA_SIZE_U8; match new_listener("_pa").await { Ok(mut incoming) => { loop { if let Some(result) = incoming.next().await { match result { Ok(stream) => { let mut stream = Connection::new(stream); let mut device: String = "".to_owned(); if let Some(Ok(Some(Data::Config((_, Some(x)))))) = stream.next_timeout2(1000).await { device = x; } if !device.is_empty() { device = crate::platform::linux::get_pa_source_name(&device); } if device.is_empty() { device = crate::platform::linux::get_pa_monitor(); } if device.is_empty() { continue; } let spec = pulse::sample::Spec { format: pulse::sample::Format::F32le, channels: 2, rate: crate::platform::PA_SAMPLE_RATE, }; log::info!("pa monitor: {:?}", device); // systemctl --user status pulseaudio.service let mut buf: Vec = vec![0; AUDIO_DATA_SIZE_U8]; match psimple::Simple::new( None, // Use the default server &crate::get_app_name(), // Our application’s name pulse::stream::Direction::Record, // We want a record stream Some(&device), // Use the default device "record", // Description of our stream &spec, // Our sample format None, // Use default channel map None, // Use default buffering attributes ) { Ok(s) => loop { if let Ok(_) = s.read(&mut buf) { let out = if buf.iter().filter(|x| **x != 0).next().is_none() { vec![] } else { buf.clone() }; if let Err(err) = stream.send_raw(out.into()).await { log::error!("Failed to send audio data:{}", err); break; } } }, Err(err) => { log::error!("Could not create simple pulse: {}", err); } } } Err(err) => { log::error!("Couldn't get pa client: {:?}", err); } } } } } Err(err) => { log::error!("Failed to start pa ipc server: {}", err); } } } /// Filesystem path of the `_drm` capture socket. It lives beside the hardened `_service` socket in /// the shared `/tmp/-service` directory (cross-uid, traversable) so the root `--service` and /// the user `--server` share one uid-independent path. Derived from the real `_service` path so we /// inherit hbb_common's directory convention WITHOUT teaching hbb_common about a drm-specific /// postfix (keeps the isolation clean: no shared-lib change). Both ends call this. #[cfg(all(target_os = "linux", feature = "drm"))] pub(crate) fn drm_ipc_path() -> String { let service_path = Config::ipc_path("_service"); let dir = std::path::Path::new(&service_path) .parent() .unwrap_or_else(|| std::path::Path::new("/tmp")); dir.join("ipc_drm").to_string_lossy().into_owned() } /// Connect (from the user `--server`) to the root service's `_drm` capture channel. Uses the /// derived `drm_ipc_path()` rather than `Config::ipc_path` since `_drm` is not a hbb_common /// service postfix (Option 2 isolation — no shared-lib change). Returns a [`DrmConn`] (bespoke /// SCM_RIGHTS framing) rather than the `Framed<_, BytesCodec>` `ConnectionTmpl`: the `_drm` channel /// must carry the scanout dma-buf fd as ancillary data, which the codec cannot do (see `DrmConn`). #[cfg(all(target_os = "linux", feature = "drm"))] pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType { use std::os::fd::AsRawFd; let path = drm_ipc_path(); let stream = timeout(ms_timeout, tokio::net::UnixStream::connect(&path)).await??; // The producer MUST be root. DRM/KMS scanout export is a root-service capability, and the DRM // path outranks PipeWire (an available DRM stream suppresses the portal consent prompt), so a // non-root peer that won a socket-path race must not be trusted to supply the display list, // frames and an arbitrary dma-buf fd (review 4.1). The producer direction is authorized in // handle_drm_conn; this closes the same gap on the consumer direction. if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { bail!("drm: _drm producer is not root; refusing to consume"); } Ok(DrmConn::new(stream)) } /// Bind the `_drm` listener. Unlike `new_listener`, this does not route through hbb_common's /// service-postfix machinery — it places the socket in the shared service dir directly, so the /// drm-off build needs no hbb_common change. The socket is 0666 (world-connectable) so the /// unprivileged `--server` can reach it; every accepted peer is still authorized in /// `handle_drm_conn` (root or the active session uid + exe identity), so connectable != authorized. #[cfg(all(target_os = "linux", feature = "drm"))] async fn new_drm_listener() -> ResultType { let path = drm_ipc_path(); // Ensure the shared service dir exists at its hardened (0711) mode. Passing the `_service` // postfix reuses hbb_common's expected mode for that directory; it only creates/chmods the // directory (no pid/socket side effects) and is idempotent with the real `_service` listener. let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; // Clear any stale socket from a previous run before binding. std::fs::remove_file(&path).ok(); let mut endpoint = Endpoint::new(path.clone()); endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); let incoming = endpoint.incoming()?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { std::fs::remove_file(&path).ok(); err })?; log::info!("Started drm ipc server at path: {}", &path); Ok(incoming) } /// Message from a per-connection DRM worker thread (which owns the `!Send` `DrmReader`) to its /// async socket task. The worker does the blocking device I/O; the task only forwards to the wire. #[cfg(all(target_os = "linux", feature = "drm"))] enum DrmProducerMsg { /// Enumerated displays, sent once before any frame so the task can answer the handshake. Displays(Vec), /// A captured frame (split/zero-copy path): the serializable dma-buf descriptor plus the (owned) /// scanout fd to hand to the peer via SCM_RIGHTS. The worker always produces a real `fd` here; the /// async task's `ExportLedger` decides whether to actually attach it (`desc.has_fd`) or elide it as /// an import-once cache hit. The `OwnedFd` is closed once the send has dup'd it into the peer (or /// immediately, when elided). Frame { desc: DmabufDesc, fd: Option, }, /// A captured frame (CPU-mapped fallback path): a full packed-BGRA frame body. Used when the /// loaded libdrmtap predates the split API (no `drmtap_grab_desc`) or the seat has no transferable /// dma-buf (ENOTSUP). Forwarded as `Data::DrmFrame{width,height}` + `send_raw(BGRA)`, exactly like /// the pre-split protocol, so an unprivileged converter is never required. FrameCpu { width: u32, height: u32, data: Bytes, }, /// A changed hardware-cursor shape + its packed RGBA pixels. Cursor { id: u64, width: u32, height: u32, hotx: i32, hoty: i32, colors: Vec, }, } /// Sets the shared stop flag when the async task ends (any path), so the blocking worker thread /// terminates promptly even while it is between channel sends (e.g. spinning on WouldBlock). #[cfg(all(target_os = "linux", feature = "drm"))] struct DrmStopGuard(std::sync::Arc); #[cfg(all(target_os = "linux", feature = "drm"))] impl Drop for DrmStopGuard { fn drop(&mut self) { self.0.store(true, std::sync::atomic::Ordering::Relaxed); } } /// Producer-side fd-elision ledger (root `--service`, one per `_drm` connection). Decides, per /// exported frame, whether the scanout dma-buf fd must ride an SCM_RIGHTS cmsg (`has_fd = true`) or /// can be elided as an import-once cache hit (`has_fd = false`) because the peer's converter already /// imported that `fb_id`. Keyed by `fb_id -> (modifier, dims)`; a change in any of those (a resize, /// a modifier/tiling change, or a recycled fb_id that also changed geometry) forces a real fd, and a /// modeset/hotplug that invalidates the CRTC ends the connection (so a reconnect starts with a fresh, /// empty ledger — matching the peer's fresh, empty converter cache). /// /// SAFETY / CORRECTNESS: eliding relies solely on `(fb_id, modifier, dims)` uniquely identifying a /// buffer, but the kernel can recycle an `fb_id` onto a *different* buffer with identical geometry /// and modifier; eliding then would serve a stale EGLImage. libdrmtap's own import cache keys on /// `fb_id + dma-buf inode` and can re-import ONLY when it is handed a real fd. Because always sending /// the fd is cheap (the converter still imports once per `fb_id` and closes the surplus fd) and is /// strictly safe, `DRM_FD_ELISION` defaults to `false` for v1 (always send). The ledger's `epoch` /// tracks `DRM_DISPLAY_GENERATION` (bumped by the udev listener on a connector-topology change), so a /// hotplug/modeset invalidates every cached buffer and forces a real fd; but the ledger still cannot /// see the dma-buf inode, so a recycled fb_id within the SAME generation (identical geometry + /// modifier) would elide onto a stale EGLImage. Enabling elision needs that inode case validated /// first. #[cfg(all(target_os = "linux", feature = "drm"))] const DRM_FD_ELISION: bool = false; #[cfg(all(target_os = "linux", feature = "drm"))] struct SeenBuf { modifier: u64, dims: (u32, u32), epoch: u64, } #[cfg(all(target_os = "linux", feature = "drm"))] struct ExportLedger { seen: HashMap, order: std::collections::VecDeque, // insertion order, for evict-oldest epoch: u64, } #[cfg(all(target_os = "linux", feature = "drm"))] impl ExportLedger { // Grow-once, hard-capped (preallocated model): a hostile/buggy peer or a fb_id churn cannot grow // this unbounded; oldest keys are evicted so a real fd is simply re-sent for them later. const MAX_LEDGER: usize = 32; fn new() -> Self { Self { seen: HashMap::new(), order: std::collections::VecDeque::new(), epoch: 0, } } /// Returns true if this frame's fd must be attached (new/changed/recycled buffer, caching /// disabled, or elision off), false if the converter already holds `fb_id` imported. fn should_send_fd(&mut self, desc: &DmabufDesc) -> bool { // fb_id == 0 disables caching for that frame; elision-off always sends. if !DRM_FD_ELISION || desc.fb_id == 0 { return true; } let ident = SeenBuf { modifier: desc.modifier, dims: (desc.width, desc.height), epoch: self.epoch, }; if let Some(prev) = self.seen.get(&desc.fb_id) { if prev.modifier == ident.modifier && prev.dims == ident.dims && prev.epoch == ident.epoch { return false; // import-once cache hit: elide the fd } } else { // New key: record insertion order and evict the oldest if at capacity. if self.order.len() >= Self::MAX_LEDGER { if let Some(old) = self.order.pop_front() { self.seen.remove(&old); } } self.order.push_back(desc.fb_id); } self.seen.insert(desc.fb_id, ident); true } } /// Build a [`DrmConn`] from an already-authorized `_drm` `Connection` (root `--service` side). The /// parity `Connection` wraps a tokio `UnixStream` but exposes no way to move it out, so we `dup()` /// its fd into a fresh, independently-owned tokio `UnixStream` for the bespoke SCM_RIGHTS framing. /// A dup gives a NEW fd number, which registers as its own epoll entry in tokio's reactor (reusing /// the same fd number would double-register); the caller drops the parity `Connection` afterwards, /// closing ITS fd, while the dup keeps the socket alive via the shared open file description. #[cfg(all(target_os = "linux", feature = "drm"))] fn dup_to_drm_conn(stream: &Connection) -> ResultType { let raw = stream.inner.get_ref().as_raw_fd(); let dup = unsafe { hbb_common::libc::dup(raw) }; if dup < 0 { return Err(std::io::Error::last_os_error().into()); } // SAFETY: `dup` is a freshly dup'd, owned fd for a connected SOCK_STREAM unix socket. let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(dup) }; std_stream.set_nonblocking(true)?; let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; Ok(DrmConn::new(tokio_stream)) } /// Cached DRM display enumeration. The pre-warm populates it and each capture open refreshes it, so /// a consumer's handshake can send the display list without first paying a DRM enumeration open. #[cfg(all(target_os = "linux", feature = "drm"))] static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); /// Monotonic generation bumped by the udev DRM-uevent listener ONLY when a connector-topology change /// actually altered `DRM_DISPLAY_CACHE` (a monitor hotplug/unplug/modeset). Each live `handle_drm_conn` /// forward loop watches this (one atomic load per frame) and, on a bump, pushes a `DrmDisplaysChanged` /// with the fresh list to its consumer — the cheap live-refresh path that avoids a consumer re-probe. /// `Release`/`Acquire` order it after the cache write so a reader that sees the new generation also sees /// the new cache (the cache `Mutex` re-synchronizes the contents regardless). #[cfg(all(target_os = "linux", feature = "drm"))] static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Snapshot a reader's enumerated displays as the IPC `DrmDisplayInfo` form. `displays()` lists all /// device outputs regardless of the reader's target CRTC, so a capture reader can refresh the cache. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_displays_from_reader(reader: &mut scrap::drm_reader::DrmReader) -> Vec { reader .displays() .into_iter() // Only offer outputs actually bound to a CRTC (i.e. scanning out). A // CONNECTED-but-unbound connector (e.g. a virtual/dummy HDMI plug the // compositor is not driving) enumerates with `crtc_id == 0`. Such an // entry has no scanout to capture, yet was still shipped to the client as // a selectable monitor; picking it made libdrmtap's `open(crtc=0)` // AUTO-SELECT the first active CRTC (the primary) and stream ITS frames at // the wrong geometry (e.g. a 3840x2160 frame into a 1280x1024 encoder -> // `src rect > dst rect`), which failed every frame and drove a ~1/sec // capturer restart loop (the flap that leaked EGL contexts to OOM). Drop // these here so they are never offered; the client keeps its real monitors. .filter(|d| d.active && d.crtc_id != 0) .map(|d| DrmDisplayInfo { name: d.name, crtc_id: d.crtc_id, x: d.x, y: d.y, width: d.width, height: d.height, active: d.active, }) .collect() } /// True if a kernel uevent datagram is a DRM-subsystem topology change (a connector hotplug/modeset). /// A uevent is NUL-separated `KEY=value` records; we require `SUBSYSTEM=drm` plus a `change` action or /// `HOTPLUG=1`, so an `add`/`remove` of an unrelated node (a render device, a fb) does not trigger a /// re-enumeration. Byte-exact record matching avoids any allocation/UTF-8 handling on the hot recv path. #[cfg(all(target_os = "linux", feature = "drm"))] fn uevent_is_drm_change(msg: &[u8]) -> bool { let mut is_drm = false; let mut is_change = false; for rec in msg.split(|&b| b == 0) { if rec == b"SUBSYSTEM=drm" { is_drm = true; } else if rec == b"ACTION=change" || rec == b"HOTPLUG=1" { is_change = true; } } is_drm && is_change } /// Listen for DRM connector hotplug/modeset uevents and refresh the display cache when the topology /// actually changes. Uses a raw `NETLINK_KOBJECT_UEVENT` socket (the same hotplug stream udev consumes) /// so no libudev dependency is added; the root `--service` already runs privileged and joining the /// kernel-uevent multicast group needs no extra cap. On a real change it re-enumerates (off any hot /// path — this is a dedicated thread, so the blocking `open`/`displays` is fine), and only when the /// enumerated set differs does it swap `DRM_DISPLAY_CACHE` and bump `DRM_DISPLAY_GENERATION`; live /// `handle_drm_conn` loops then push the fresh list to their consumers. Best-effort: if the socket is /// unavailable it logs and returns, and DRM capture still works (a consumer reconnect re-reads the /// fresh list) — just without the mid-session live refresh. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_udev_listener() { use hbb_common::libc; use std::sync::atomic::Ordering; let sock = unsafe { libc::socket( libc::AF_NETLINK, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, libc::NETLINK_KOBJECT_UEVENT, ) }; if sock < 0 { log::info!( "drm: udev uevent socket unavailable ({}); hotplug refresh disabled", std::io::Error::last_os_error() ); return; } // Own the fd so it is closed on every return / unwind path. let _owned = unsafe { OwnedFd::from_raw_fd(sock) }; let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; addr.nl_family = libc::AF_NETLINK as u16; // Group 1 = kernel-originated uevents (udev re-broadcasts on group 2); pid 0 => kernel assigns. addr.nl_groups = 1; let rc = unsafe { libc::bind( sock, &addr as *const libc::sockaddr_nl as *const libc::sockaddr, std::mem::size_of::() as libc::socklen_t, ) }; if rc < 0 { log::info!( "drm: udev uevent bind failed ({}); hotplug refresh disabled", std::io::Error::last_os_error() ); return; } log::info!("drm: udev DRM-uevent listener started"); // Fixed-size receive buffer (preallocated model): a uevent is well under 8 KiB; a rare larger // datagram is truncated by `recv` and simply re-enumerates on the next matching event. let mut buf = [0u8; 8192]; loop { // recvmsg (not recv) so the source address is available: bound to the kernel-uevent multicast // group, a genuine uevent comes from the kernel (source nl_pid == 0) via a multicast group // (nl_groups != 0). A local unprivileged process could otherwise UNICAST a spoofed // "change@.../drm/..." datagram to this root listener and drive it to re-enumerate at will; // dropping any non-kernel/non-multicast source closes that. let mut src: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; let mut iov = libc::iovec { iov_base: buf.as_mut_ptr() as *mut libc::c_void, iov_len: buf.len(), }; let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() }; mhdr.msg_name = &mut src as *mut libc::sockaddr_nl as *mut libc::c_void; mhdr.msg_namelen = std::mem::size_of::() as libc::socklen_t; mhdr.msg_iov = &mut iov; mhdr.msg_iovlen = 1; let n = unsafe { libc::recvmsg(sock, &mut mhdr, 0) }; if n <= 0 { let err = std::io::Error::last_os_error(); if n < 0 && err.kind() == std::io::ErrorKind::Interrupted { continue; } log::info!("drm: udev uevent recv ended ({err}); hotplug refresh stopped"); break; } // Trust only a kernel-originated (nl_pid == 0), multicast-delivered (nl_groups != 0) datagram // with a full source address; drop a unicast or user-spoofed message. if (mhdr.msg_namelen as usize) < std::mem::size_of::() || src.nl_pid != 0 || src.nl_groups == 0 { continue; } if !uevent_is_drm_change(&buf[..n as usize]) { continue; } // Re-enumerate and diff. Only a real change swaps the cache + bumps the generation, so a // uevent that does not alter the captured topology stays silent (no consumer churn). if let Some(mut r) = scrap::drm_reader::DrmReader::open(None, 0) { let fresh = drm_displays_from_reader(&mut r); let changed = { let mut cache = DRM_DISPLAY_CACHE.lock().unwrap(); if *cache != fresh { *cache = fresh; true } else { false } }; if changed { DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release); log::info!("drm: connector topology changed (udev); display cache refreshed"); } } } } /// Best-effort warm-up at listener start: loads libdrmtap, initializes EGL, enumerates displays into /// the cache, and maps the first framebuffer once. Moves that one-time cost (which otherwise lands /// on the first consumer and can push the first frame past the client's initial-frame timeout) off /// the critical path. Runs on its own thread since `DrmReader` is `!Send` and `open`/`grab` block. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_prewarm() { let t = std::time::Instant::now(); match scrap::drm_reader::DrmReader::open(None, 0) { Some(mut r) => { let displays = drm_displays_from_reader(&mut r); let n = displays.len(); // Warm the first framebuffer export. On the split path, grab_desc() exports a dma-buf fd // WITHOUT loading libEGL/libGLESv2 into the root service (the convert now runs in the // unprivileged --server); only an old .so (no grab_desc) still force-maps via grab(). if r.supports_grab_desc() { if let Ok((fd, _desc)) = r.grab_desc() { drop(fd); // close the warm-up fd; we only wanted to prime the device/import path } } else { let _ = r.grab(); } *DRM_DISPLAY_CACHE.lock().unwrap() = displays; log::info!("drm: pre-warm ok ({n} displays) in {:?}", t.elapsed()); } None => log::info!("drm: pre-warm skipped (reader unavailable)"), } } /// DRM/KMS capture producer. Runs in the ROOT `--service` (which holds CAP_SYS_ADMIN, so libdrmtap /// reads the scanout in-process — no helper, no setcap). One dedicated `current_thread` runtime /// owns the `_drm` listener and `tokio::spawn`s a task per accepted consumer, so a multi-monitor /// client (which opens one `_drm` connection per captured display) is served CONCURRENTLY instead /// of serially. The `!Send` `DrmReader` never runs on this runtime: each connection offloads its /// blocking `grab()` loop to a private std worker thread (see `handle_drm_conn`), which keeps the /// connection future `Send` (thus spawnable) and lets the tasks multiplex on the one listener /// thread while the workers capture in parallel. #[cfg(all(target_os = "linux", feature = "drm"))] #[tokio::main(flavor = "current_thread")] pub async fn start_drm() { match new_drm_listener().await { Ok(mut incoming) => { // Warm libdrmtap/EGL + enumeration off-thread so the first consumer does not pay that // one-time cost on its critical path. std::thread::spawn(drm_prewarm); // Watch for connector hotplug/modeset uevents so a mid-session topology change refreshes // the display cache and is pushed to live consumers (best-effort; own thread since it // blocks on recv and re-enumeration is a blocking `!Send` open). std::thread::spawn(drm_udev_listener); loop { match incoming.next().await { Some(Ok(stream)) => { tokio::spawn(async move { if let Err(err) = handle_drm_conn(Connection::new(stream)).await { log::info!("drm ipc connection ended: {}", err); } }); } Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), // Stream exhausted: without this the `if let Some` form would re-poll the dead // stream forever and busy-spin the root service. Stop the producer instead. None => { log::error!("drm ipc listener stream ended; stopping drm producer"); break; } } } } Err(err) => { log::error!("Failed to start drm ipc server: {}", err); } } } /// Handle one `_drm` consumer. `DrmReader` is `!Send` and `grab()` is a blocking C call, so it /// cannot live on the shared listener runtime; this task spawns a private std worker thread that /// owns the reader (`drm_capture_worker`) and streams `DrmProducerMsg`s back over a bounded channel /// (capacity 2 = backpressure: a slow consumer throttles capture instead of growing memory). The /// task itself stays fully async — hence `Send`, hence `tokio::spawn`able — and only forwards /// messages to the wire. On any error / disconnect it returns; the `DrmStopGuard` plus dropping the /// channels tears the worker down, and the client falls back to PipeWire/portal. /// Concurrency cap on accepted `_drm` consumer connections. Each accepted consumer spawns a worker /// that opens a DRM context, so even though the peer is authorized we still bound how many a single /// (buggy or compromised) --server can open, to keep it from exhausting root-service threads/memory. /// One connection per served display is plenty; the slack covers a reconnect overlapping an old worker /// still tearing down. #[cfg(all(target_os = "linux", feature = "drm"))] const MAX_DRM_CONNS: usize = 8; /// Whether a new `_drm` connection is admitted, given the live count taken BEFORE it (the value /// `AtomicUsize::fetch_add` returns). Pure, so the admission bound is unit-testable without the runtime /// counter: admit while strictly below the cap, reject at or above it. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_conn_admitted(prev_count: usize) -> bool { prev_count < MAX_DRM_CONNS } /// Whether a `_drm` peer may keep receiving frames (review 3.3): root (uid 0) always, any other peer /// only while it still matches the active-session uid, and an unknown peer never (fail closed). Pure, /// so the per-frame re-authorization decision is unit-testable without a live logind session. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_peer_authorized(peer_uid: Option, active_uid: Option) -> bool { match peer_uid { Some(0) => true, Some(uid) => active_uid == Some(uid), None => false, } } #[cfg(all(target_os = "linux", feature = "drm"))] async fn handle_drm_conn(stream: Connection) -> ResultType<()> { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; // The `_drm` socket is world-connectable (0666) so the unprivileged `--server` can reach it, // so we MUST authorize the peer here — this is a dedicated listener that does not go through // the generic `start()` accept loop where service-scoped channels are checked. Same policy as // `_service`: peer must be root or the active session uid, with a `/proc/pid/exe` identity // match. Without this any local process could connect and receive the screen contents. if !authorize_service_scoped_ipc_connection(&stream, "_drm") { log::warn!("drm: rejected unauthorized connection to _drm"); return Ok(()); } // Admission bound: each accepted _drm consumer spawns a worker thread that opens a DRM context. // The peer is authorized (root/active-session), but we still cap concurrency so a buggy or // compromised --server cannot exhaust root-service threads/memory by opening an unbounded number // of streams. One connection per served display is plenty; MAX_DRM_CONNS covers multi-monitor // plus a little slack for a reconnect overlapping an old worker still tearing down. static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); struct DrmConnGuard; impl Drop for DrmConnGuard { fn drop(&mut self) { DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); } } if !drm_conn_admitted(DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst)) { DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); return Ok(()); } let _conn_guard = DrmConnGuard; // Capture the peer uid now so the forward loop can RE-authorize every frame. The check above runs // once at accept, but DRM/KMS capture is NOT session-scoped: `drm_capture_worker` grabs the // physical scanout of a CRTC regardless of which session currently owns the display. So a stream // authorized for one session must stop the moment the active session changes, or the outgoing // user's --server keeps receiving the incoming user's screen (and the greeter in between) until // the socket dies (review 3.3). `peer_uid` is the --server's fixed uid. let peer_uid = stream.peer_uid(); // Move the authorized `_drm` stream onto the bespoke SCM_RIGHTS framing (see `DrmConn`). ALL // further traffic — display list, `DrmStart`, frame descriptors + their ancillary fd, and the // cursor / CPU-fallback bodies — goes through `conn` so no `Framed` read buffer ever competes with // a `recvmsg` for the fd. The parity `Connection` (used only for the authorization above) is // dropped here, closing its fd; the dup inside `conn` keeps the socket alive. let mut conn = dup_to_drm_conn(&stream)?; drop(stream); // worker -> task: display list, frames, cursor (bounded = backpressure). let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); // task -> worker: the chosen CRTC + whether the consumer needs the CPU path, sent once after the // client's DrmStart. let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<(u32, bool)>(); let stop = Arc::new(AtomicBool::new(false)); let _stop_guard = DrmStopGuard(stop.clone()); let worker_stop = stop.clone(); std::thread::spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop)); // Handshake: the worker sends the display list (from the pre-warmed cache, or a throwaway // enumeration open if the cache is empty). A closed channel (no Displays) means the reader was // unavailable, so let the client fall back. let displays = match frame_rx.recv().await { Some(DrmProducerMsg::Displays(d)) => d, _ => { log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); return Ok(()); } }; conn.send_msg(&Data::DrmDisplayList(displays.clone()), None).await?; // Wait for the client to choose a display before streaming. `recv_msg_timeout2` gates only the // wait for the first byte, so a timeout leaves the stream at a clean frame boundary. let (display_idx, need_cpu) = loop { match conn.recv_msg_timeout2(10_000).await { Some(Ok((Data::DrmStart { display, need_cpu }, _fd))) => break (display, need_cpu), Some(Ok((_, _fd))) => continue, // ignore unexpected messages; drop any stray fd Some(Err(e)) => return Err(e), None => return Ok(()), // timed out: client never chose a display } }; // Resolve the chosen display's CRTC. `displays` here is already filtered to // CRTC-bound outputs (see drm_displays_from_reader), so a valid selection // always yields a non-zero crtc_id. Reject a 0 (out-of-range index, or an // unbound display that somehow slipped through) rather than passing it to // `open(crtc=0)`, whose "auto-select the first/primary CRTC" sentinel would // silently stream the WRONG monitor at a mismatched geometry and flap the // capturer. Closing lets the consumer fall back (PipeWire) for that display. let target_crtc = usize::try_from(display_idx) .ok() .and_then(|i| displays.get(i)) .map(|d| d.crtc_id) .unwrap_or(0); if target_crtc == 0 { log::warn!( "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" ); return Ok(()); } // Hand the CRTC + the consumer's CPU-path request to the worker; an error means it already gave up // (reader vanished). if crtc_tx.send((target_crtc, need_cpu)).is_err() { return Ok(()); } // Forward frames + cursor updates until the worker ends or the client disconnects (a wire send // error on a dropped client propagates out and tears the worker down via the guard). The // per-connection `ExportLedger` decides, for the zero-copy path, whether each frame's fd must ride // an SCM_RIGHTS cmsg or can be elided as an import-once cache hit. let mut ledger = ExportLedger::new(); // Live hotplug: the udev listener bumps DRM_DISPLAY_GENERATION when the connector topology changes. // Seed from the value current at handshake (the list already sent reflects it) and, whenever it // moves, push the fresh list to this consumer. Piggybacked on the frame cadence so it costs only one // atomic load per frame; a genuinely idle stream tears down after MAX_STALLED and the consumer // reconnects to a fresh list anyway. let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); while let Some(first) = frame_rx.recv().await { // Re-authorize per frame (review 3.3): root (0) is always allowed; any other peer must still be // the active-session uid. Use the CACHE-ONLY active uid (never a blocking loginctl lookup): this // runs on the single-threaded `_drm` runtime, so a per-frame seat0 subprocess -- which is // exactly what a fresh lookup does during a session switch, when the cache is momentarily empty // -- would stall every stream. A cache miss (`None`) means the active session is momentarily // unknown (typically mid-switch), which we treat as fail-closed and stop. The stop latency is // therefore bounded by the service loop's active-uid cache cadence (a few hundred ms), plus we // stop as soon as the cache goes empty at the start of a switch. let peer_ok = drm_peer_authorized(peer_uid, active_uid_cached()); if !peer_ok { log::warn!("drm: _drm peer no longer matches the active session (or it is unknown); closing"); break; } let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); // Keep the ledger's epoch at the live generation so a hotplug/modeset (which may recycle an // fb_id onto a new buffer) invalidates every cached buffer and forces a real fd on the next // frame. Cheap (one field write) and only observable when DRM_FD_ELISION is enabled. ledger.epoch = gen; if gen != seen_gen { seen_gen = gen; let fresh = DRM_DISPLAY_CACHE.lock().unwrap().clone(); if !fresh.is_empty() { conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; } } // Coalesce to latest-wins at the source (review 4.8). The `_drm` socket is a FIFO, so a // consumer that drains slower than we produce (a 4K convert on a modest GPU) would fall // seconds behind stale frames. Drain everything already queued without blocking and forward // only the NEWEST frame; each replaced frame drops here, closing its OwnedFd (zero-copy path) // and freeing its pixel buffer (CPU path). Cursor updates are latency-insensitive state // (latest-wins by id downstream), so they are forwarded in order and never coalesced away. let mut latest_frame: Option = None; let mut msg = Some(first); while let Some(m) = msg.take() { match m { f @ (DrmProducerMsg::Frame { .. } | DrmProducerMsg::FrameCpu { .. }) => { latest_frame = Some(f); } DrmProducerMsg::Cursor { id, width, height, hotx, hoty, colors, } => { conn.send_msg( &Data::DrmCursor { id, width, height, hotx, hoty, }, None, ) .await?; conn.send_raw(Bytes::from(colors)).await?; } DrmProducerMsg::Displays(_) => {} } msg = frame_rx.try_recv().ok(); } match latest_frame { Some(DrmProducerMsg::Frame { mut desc, fd }) => { // The worker always supplies a real fd; the ledger decides whether to attach it. let send_fd = fd.is_some() && ledger.should_send_fd(&desc); desc.has_fd = send_fd; let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; // `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it into // the peer). Closing immediately bounds our fd usage to ~1 in flight per frame. } Some(DrmProducerMsg::FrameCpu { width, height, data, }) => { // CPU-mapped fallback: pixels cross the wire, exactly like the pre-split protocol. conn.send_msg(&Data::DrmFrame { width, height }, None).await?; conn.send_raw(data).await?; } _ => {} } } Ok(()) } /// The blocking half of a `_drm` connection: owns the `!Send` `DrmReader`(s) on its own thread and /// streams messages to the async task. Ends (thread exits, reader closes) when the device is /// unavailable, errors/stalls, or the task drops the channels / sets the stop flag. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_capture_worker( frame_tx: tokio::sync::mpsc::Sender, crtc_rx: std::sync::mpsc::Receiver<(u32, bool)>, stop: std::sync::Arc, ) { use std::sync::atomic::Ordering; use std::time::Duration; // ~30 fps producer ceiling; the consumer's encoder/QoS sets the effective rate and the bounded // channel throttles us further if it is slower. Also avoids a busy-spin when `grab()` returns // the same scanout repeatedly. const FRAME_INTERVAL: Duration = Duration::from_millis(33); // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5s) instead // of freezing forever; the client then falls back. const MAX_STALLED: u32 = 150; let t_conn = std::time::Instant::now(); // Send the display list. Prefer the pre-warmed cache (skips a per-connection enumeration open); // fall back to a throwaway enumeration reader if the pre-warm has not populated it yet. let displays = { let cached = DRM_DISPLAY_CACHE.lock().unwrap().clone(); if !cached.is_empty() { cached } else { let mut enum_reader = match scrap::drm_reader::DrmReader::open(None, 0) { Some(r) => r, None => return, }; drm_displays_from_reader(&mut enum_reader) } }; if frame_tx .blocking_send(DrmProducerMsg::Displays(displays)) .is_err() { return; } // Wait for the task to relay the client's chosen CRTC + CPU-path request (Err => the task gave up // / disconnected). let (target_crtc, need_cpu) = match crtc_rx.recv() { Ok(c) => c, Err(_) => return, }; let t_open = std::time::Instant::now(); let mut reader = match scrap::drm_reader::DrmReader::open(None, target_crtc) { Some(r) => r, None => { log::warn!("drm: failed to open crtc {target_crtc}; closing _drm connection"); // The cached display list handed out a CRTC that no longer opens (a hotplug/modeset // likely invalidated it). Drop the cache so the next connection re-enumerates from the // live device instead of serving the same stale, unopenable CRTC on every reconnect. DRM_DISPLAY_CACHE.lock().unwrap().clear(); return; } }; // Refresh the cache from the live device so the next consumer's handshake uses fresh geometry. *DRM_DISPLAY_CACHE.lock().unwrap() = drm_displays_from_reader(&mut reader); log::debug!( "drm: capture reader for crtc {target_crtc} opened in {:?}", t_open.elapsed() ); // A per-connection buffer-pool epoch so `buffer_id` is unique across connections even for the same // fb_id (the consumer may key a pool by buffer_id). static DRM_CONN_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed); // Prefer the zero-copy split export (root does NO EGL / convert / copy). Fall back to the // CPU-mapped path for this connection (pixels cross the wire) when: the loaded libdrmtap predates // the split API, grab_desc later reports ENOTSUP (no transferable dma-buf on this seat), OR the // consumer asked for the CPU path because it has no render-node convert context (need_cpu) — in // that last case the dma-buf fd would be useless to it and the stream would be lost. let mut use_dmabuf = reader.supports_grab_desc() && !need_cpu; let mut last_cursor_id: u64 = 0; let mut stalled: u32 = 0; let mut logged_first = false; while !stop.load(Ordering::Relaxed) { // Grab one frame in the current mode, producing an OWNED message (no borrow of `reader` // outlives this, so `reader.cursor()` below is free to run). The dma-buf path ships only the // descriptor + fd; the CPU path copies the packed BGRA once (Bytes::copy_from_slice). let grabbed: std::io::Result = if use_dmabuf { match reader.grab_desc() { Ok((fd, d)) => Ok(DrmProducerMsg::Frame { desc: DmabufDesc { buffer_id: (d.fb_id as u64) | ((conn_epoch as u64) << 32), width: d.width, height: d.height, format: d.format, modifier: d.modifier, fb_id: d.fb_id, num_planes: d.num_planes, offsets: d.offsets, pitches: d.pitches, hdr_eotf: d.hdr_eotf, hdr_max_nits: d.hdr_max_nits, has_fd: true, // the async task's ExportLedger may downgrade this }, fd: Some(fd), }), Err(err) => Err(err), } } else { match reader.grab() { Ok((buf, w, h)) => Ok(DrmProducerMsg::FrameCpu { width: w as u32, height: h as u32, data: Bytes::copy_from_slice(buf), }), Err(err) => Err(err), } }; match grabbed { Ok(msg) => { stalled = 0; if !logged_first { logged_first = true; log::debug!( "drm: first frame for crtc {target_crtc} in {:?} ({} path)", t_conn.elapsed(), if use_dmabuf { "dma-buf" } else { "cpu" } ); } if frame_tx.blocking_send(msg).is_err() { break; } } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { stalled += 1; if stalled > MAX_STALLED { log::info!("drm: capture stalled (no frame); closing _drm connection"); break; } std::thread::sleep(FRAME_INTERVAL); continue; } Err(err) if use_dmabuf && err.kind() == std::io::ErrorKind::Unsupported => { // The split export cannot work on this seat/driver (ENOTSUP). Switch this connection // to the CPU-mapped fallback (pixels over the wire) instead of tearing down or // rebuild-looping; the reader is already open and usable via grab(). log::warn!( "drm: grab_desc unsupported ({err}); switching to CPU-mapped fallback for this connection" ); use_dmabuf = false; logged_first = false; continue; } Err(err) => { log::warn!("drm: capture error: {err}; closing _drm connection"); break; } } // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). if let Some(c) = reader.cursor() { if c.id != last_cursor_id { last_cursor_id = c.id; if frame_tx .blocking_send(DrmProducerMsg::Cursor { id: c.id, width: c.width, height: c.height, hotx: c.hotx, hoty: c.hoty, colors: c.colors, }) .is_err() { break; } } } std::thread::sleep(FRAME_INTERVAL); } } pub struct ConnectionTmpl { inner: Framed, } pub type Connection = ConnectionTmpl; impl ConnectionTmpl where T: AsyncRead + AsyncWrite + std::marker::Unpin, { pub fn new(conn: T) -> Self { Self { inner: Framed::new(conn, BytesCodec::new()), } } pub async fn send(&mut self, data: &Data) -> ResultType<()> { let v = serde_json::to_vec(data)?; self.inner.send(bytes::Bytes::from(v)).await?; Ok(()) } async fn send_config(&mut self, name: &str, value: String) -> ResultType<()> { self.send(&Data::Config((name.to_owned(), Some(value)))) .await } pub async fn next_timeout(&mut self, ms_timeout: u64) -> ResultType> { Ok(timeout(ms_timeout, self.next()).await??) } pub async fn next_timeout2(&mut self, ms_timeout: u64) -> Option>> { if let Ok(x) = timeout(ms_timeout, self.next()).await { Some(x) } else { None } } pub async fn next(&mut self) -> ResultType> { match self.inner.next().await { Some(res) => { let bytes = res?; if let Ok(s) = std::str::from_utf8(&bytes) { if let Ok(data) = serde_json::from_str::(s) { return Ok(Some(data)); } } return Ok(None); } _ => { bail!("reset by the peer"); } } } pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { self.inner.send(data).await?; Ok(()) } pub async fn next_raw(&mut self) -> ResultType { match self.inner.next().await { Some(Ok(res)) => Ok(res), _ => { bail!("reset by the peer"); } } } } /// Ancillary-fd transport for the `_drm` channel. /// /// `ConnectionTmpl`'s `Framed<_, BytesCodec>` cannot carry (nor collect) an SCM_RIGHTS control /// message: tokio's `AsyncRead` never does a `recvmsg` with a control buffer, so a fd sent alongside /// a `Framed` byte-frame is silently dropped on receive, and interleaving a raw `sendmsg` with the /// codec desyncs its internal read buffer. So the WHOLE `_drm` channel moves onto this bespoke /// length-prefixed `sendmsg`/`recvmsg` framing, owning the raw `tokio::net::UnixStream` directly: /// handshake (`DrmDisplayList`/`DrmStart`), frame descriptors, and the CPU-fallback/cursor bodies all /// go through it so no `Framed` read buffer ever competes with a `recvmsg`. /// /// Framing: each frame is a 4-byte big-endian length prefix + payload. `send_msg`/`recv_msg` carry a /// JSON `Data`; `send_raw`/`next_raw` carry an opaque body. The dma-buf fd (when present) rides an /// SCM_RIGHTS cmsg bound to the frame's first (prefix) byte, so reading the prefix with a control /// buffer reliably collects it (`MSG_CTRUNC` is rejected). Reads use exact-length loops so they never /// cross a frame boundary and thus never discard a following frame's ancillary fd. #[cfg(all(target_os = "linux", feature = "drm"))] pub struct DrmConn { /// The raw stream. Obtained from `connect_drm` (client) or the accepted `_drm` listener stream /// (service). All framing is done by hand on this fd; there is no `Framed` codec. stream: tokio::net::UnixStream, /// Grow-once accumulation buffer for `recv_msg`/`next_raw` length-prefixed reads (preallocated /// model: it grows to the largest frame seen and is then reused, never per-frame reallocated). read_buf: Vec, /// Set by `drm_read_full` once the current read has consumed at least one byte off the socket. /// `recv_msg` clears it before reading, and `recv_msg_timeout2` reads it to tell a spurious /// `readable()` wakeup with no frame yet (safe to re-poll -> `None`) from a peer that stalled /// mid-frame after sending some bytes (unresumable -> hard error). consumed: bool, } /// Cap on a JSON `Data` message read by `recv_msg` (headers/handshake are tiny; this only bounds a /// hostile/oversized length prefix). Distinct from the raw-body cap because a body can be a whole /// CPU-fallback frame. #[cfg(all(target_os = "linux", feature = "drm"))] const MAX_DRM_JSON_BYTES: usize = 8 * 1024 * 1024; /// Cap on a raw body read by `next_raw` (CPU-fallback BGRA / cursor RGBA). Covers a 256 MiB 8K /// scanout (`DrmReader` bounds a frame to that) with margin. #[cfg(all(target_os = "linux", feature = "drm"))] const MAX_DRM_RAW_BYTES: usize = 512 * 1024 * 1024; /// Control-buffer capacity for one SCM_RIGHTS cmsg carrying a single fd. `CMSG_SPACE(sizeof(int))` is /// 24 bytes on our targets; 64 gives headroom and the `align(8)` matches `cmsghdr` alignment. #[cfg(all(target_os = "linux", feature = "drm"))] const DRM_CMSG_CAP: usize = 64; /// Aligned storage for the SCM_RIGHTS control buffer (`msg_control` must be `cmsghdr`-aligned). #[cfg(all(target_os = "linux", feature = "drm"))] #[repr(align(8))] struct DrmCmsgBuf([u8; DRM_CMSG_CAP]); /// One non-blocking `sendmsg`: writes `buf` and, when `pass_fd` is `Some`, attaches exactly one /// SCM_RIGHTS cmsg carrying that fd. The cmsg is attached ONLY when a fd is present (a -1 fd in an /// SCM_RIGHTS cmsg fails the whole call). Returns bytes sent, or a `WouldBlock`/other io error. /// /// SAFETY: `fd` must be a valid open socket fd; `buf` a valid readable slice; `pass_fd` (if any) a /// valid open fd. The ancillary data is delivered by the kernel with the first byte of `buf`. #[cfg(all(target_os = "linux", feature = "drm"))] unsafe fn drm_sendmsg(fd: RawFd, buf: &[u8], pass_fd: Option) -> std::io::Result { use hbb_common::libc; let mut iov = libc::iovec { iov_base: buf.as_ptr() as *mut libc::c_void, iov_len: buf.len(), }; let mut msg: libc::msghdr = std::mem::zeroed(); msg.msg_iov = &mut iov; msg.msg_iovlen = 1; let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); if let Some(sfd) = pass_fd { msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; let cmsg = libc::CMSG_FIRSTHDR(&msg); // Sized above so CMSG_FIRSTHDR is non-null; guard anyway to avoid UB on any platform quirk. if cmsg.is_null() { return Err(std::io::Error::new( std::io::ErrorKind::Other, "drm: CMSG_FIRSTHDR null", )); } (*cmsg).cmsg_level = libc::SOL_SOCKET; (*cmsg).cmsg_type = libc::SCM_RIGHTS; (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; let sfd_c: libc::c_int = sfd; std::ptr::copy_nonoverlapping( &sfd_c as *const libc::c_int as *const u8, libc::CMSG_DATA(cmsg), std::mem::size_of::(), ); } let n = libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL); if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) } } /// One non-blocking `recvmsg` into `buf` with a control buffer. Collects at most one SCM_RIGHTS fd /// (any surplus fds are closed); rejects a truncated cmsg (`MSG_CTRUNC`) as a hard error after closing /// whatever it parsed. Returns (bytes read, fd). Received fds are `O_CLOEXEC` (`MSG_CMSG_CLOEXEC`). /// /// SAFETY: `fd` must be a valid open socket fd; `buf` a valid writable slice. #[cfg(all(target_os = "linux", feature = "drm"))] unsafe fn drm_recvmsg(fd: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Option)> { use hbb_common::libc; let mut iov = libc::iovec { iov_base: buf.as_mut_ptr() as *mut libc::c_void, iov_len: buf.len(), }; let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); let mut msg: libc::msghdr = std::mem::zeroed(); msg.msg_iov = &mut iov; msg.msg_iovlen = 1; msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; msg.msg_controllen = cbuf.0.len() as _; let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); if n < 0 { return Err(std::io::Error::last_os_error()); } // Walk the cmsgs; keep the first SCM_RIGHTS fd, close any extras. Each parsed int is wrapped in an // OwnedFd immediately so it is always closed on drop (no fd leak on any error path below). let mut got: Option = None; let mut cmsg = libc::CMSG_FIRSTHDR(&msg); while !cmsg.is_null() { if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { let data = libc::CMSG_DATA(cmsg); let hdr = libc::CMSG_LEN(0) as usize; let payload = ((*cmsg).cmsg_len as usize).saturating_sub(hdr); let count = payload / std::mem::size_of::(); for i in 0..count { let mut rawfd: libc::c_int = -1; std::ptr::copy_nonoverlapping( data.add(i * std::mem::size_of::()), &mut rawfd as *mut libc::c_int as *mut u8, std::mem::size_of::(), ); if rawfd >= 0 { let owned = OwnedFd::from_raw_fd(rawfd); if got.is_none() { got = Some(owned); } // else: surplus fd, dropped here -> closed } } } cmsg = libc::CMSG_NXTHDR(&msg, cmsg); } // A truncated control message means the kernel dropped fd(s) that did not fit: fail rather than // proceed with a missing/partial fd (drop `got` so anything parsed is closed first). if msg.msg_flags & libc::MSG_CTRUNC != 0 { drop(got); return Err(std::io::Error::new( std::io::ErrorKind::Other, "drm: truncated SCM_RIGHTS control message (MSG_CTRUNC)", )); } Ok((n as usize, got)) } /// Write all of `buf` to `stream`, attaching `pass_fd` (if any) to the FIRST byte (the kernel binds /// SCM_RIGHTS ancillary to the first data byte of the `sendmsg` that carried it). Loops on /// `WouldBlock` via `writable()`; the fd is attached only until the first `sendmsg` sends >= 1 byte. #[cfg(all(target_os = "linux", feature = "drm"))] async fn drm_write_all( stream: &tokio::net::UnixStream, mut buf: &[u8], mut pass_fd: Option, ) -> ResultType<()> { while !buf.is_empty() { stream.writable().await?; let raw = stream.as_raw_fd(); let chunk = buf; let fd_now = pass_fd; match stream.try_io(tokio::io::Interest::WRITABLE, || unsafe { drm_sendmsg(raw, chunk, fd_now) }) { Ok(0) => bail!("drm: socket write returned 0 (peer closed)"), Ok(n) => { pass_fd = None; // ancillary delivered with these bytes; do not re-send it buf = &buf[n..]; } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, Err(e) => return Err(e.into()), } } Ok(()) } /// Write one length-prefixed frame: a 4-byte big-endian length + payload, with `pass_fd` (if any) /// riding the prefix's first byte. #[cfg(all(target_os = "linux", feature = "drm"))] async fn drm_send_frame( stream: &tokio::net::UnixStream, payload: &[u8], pass_fd: Option, ) -> ResultType<()> { if payload.len() > u32::MAX as usize { bail!("drm: frame too large ({} bytes)", payload.len()); } let prefix = (payload.len() as u32).to_be_bytes(); // The fd rides the prefix (its first byte); the payload carries no ancillary. drm_write_all(stream, &prefix, pass_fd).await?; drm_write_all(stream, payload, None).await?; Ok(()) } /// Read exactly `buf.len()` bytes from `stream`. When `want_cmsg` is true, the FIRST read uses a /// control buffer to collect an SCM_RIGHTS fd (which the sender bound to the frame's first byte); /// subsequent reads within the same frame are plain. Returns the collected fd, if any. #[cfg(all(target_os = "linux", feature = "drm"))] async fn drm_read_full( stream: &tokio::net::UnixStream, buf: &mut [u8], want_cmsg: bool, progress: &mut bool, ) -> ResultType> { use hbb_common::libc; let mut off = 0usize; let mut got: Option = None; while off < buf.len() { stream.readable().await?; let raw = stream.as_raw_fd(); // Only the first read of a frame carries the fd (bound to byte 0); after that, plain reads. let use_cmsg = want_cmsg && got.is_none(); let n = { let dst: &mut [u8] = &mut buf[off..]; match stream.try_io(tokio::io::Interest::READABLE, move || unsafe { if use_cmsg { drm_recvmsg(raw, dst) } else { let m = libc::read(raw, dst.as_mut_ptr() as *mut libc::c_void, dst.len()); if m < 0 { Err(std::io::Error::last_os_error()) } else { Ok((m as usize, None)) } } }) { Ok((0, _fd)) => bail!("drm: socket closed by peer"), Ok((m, fd)) => { if let Some(f) = fd { if got.is_none() { got = Some(f); } } m } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, Err(e) => return Err(e.into()), } }; // Any byte off the socket commits us to this frame: a later cancellation (e.g. a recv_msg // timeout) cannot be safely re-polled, since the consumed bytes are gone from the stream. if n > 0 { *progress = true; } off += n; } Ok(got) } #[cfg(all(target_os = "linux", feature = "drm"))] impl DrmConn { /// Take ownership of an already-connected/accepted raw `_drm` stream. pub fn new(stream: tokio::net::UnixStream) -> Self { Self { stream, read_buf: Vec::new(), consumed: false, } } /// Send one `Data` message (JSON, length-prefixed). When `fd` is `Some`, attach exactly one /// SCM_RIGHTS cmsg carrying that fd on the SAME frame as the payload (a -1 in an SCM_RIGHTS cmsg /// fails the whole call, so the cmsg is attached ONLY when a fd is present). `fd` is borrowed so /// the caller keeps ownership and closes it after the send has dup'd it into the peer. pub async fn send_msg(&mut self, data: &Data, fd: Option>) -> ResultType<()> { let payload = serde_json::to_vec(data)?; let pass_fd = fd.map(|f| f.as_raw_fd()); drm_send_frame(&self.stream, &payload, pass_fd).await } /// Receive one `Data` message plus any dma-buf fd delivered via SCM_RIGHTS. Reads the 4-byte /// length prefix (with a `CMSG_SPACE(size_of::())` control buffer that collects the fd bound /// to the frame's first byte, rejecting `MSG_CTRUNC`), then the payload into the reusable /// `read_buf`. Returns the decoded `Data` and an `OwnedFd` iff one arrived. pub async fn recv_msg(&mut self) -> ResultType<(Data, Option)> { // Clear the per-frame progress flag before the first read so recv_msg_timeout2 can tell a // spurious readable() wakeup (nothing consumed) from a mid-frame stall (see its docs). self.consumed = false; let mut prefix = [0u8; 4]; // Disjoint field borrows: &self.stream (read) + &mut self.consumed (progress). `prefix` is local. let fd = drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed).await?; let len = u32::from_be_bytes(prefix) as usize; if len > MAX_DRM_JSON_BYTES { // `fd` (if any) is closed on drop. bail!("drm: message length {len} exceeds cap {MAX_DRM_JSON_BYTES}"); } if self.read_buf.len() < len { self.read_buf.resize(len, 0); } // Disjoint field borrows: &self.stream (read) + &mut self.read_buf (dest) + &mut self.consumed. // No fd on the body. drm_read_full(&self.stream, &mut self.read_buf[..len], false, &mut self.consumed).await?; let data: Data = serde_json::from_slice(&self.read_buf[..len])?; Ok((data, fd)) } /// Cancel-safe timeout wrapper around `recv_msg`, mirroring `ConnectionTmpl::next_timeout2`, so a /// dropped consumer re-checks its `stop` flag between frames. Returns `None` when no frame was in /// progress at the deadline -- either no byte was ever readable, or `readable()` fired spuriously and /// the first read found nothing -- because in that case nothing was consumed and re-polling is safe. /// Once at least one byte has been consumed the frame is committed, so the SAME budget bounds the /// rest of the read: a peer that sends part of a frame then stalls cannot pin this task forever (the /// `readable()` gate alone does not cover the length prefix or payload). Such an overrun is a hard /// error, NOT a `None`, because the consumed bytes are gone from the stream and the frame cannot be /// resumed -- the caller tears the stream down. `recv_msg` bodies are small length-prefixed JSON /// (<= MAX_DRM_JSON_BYTES), well under any caller's budget over a local socket, so this never trips /// a healthy peer. pub async fn recv_msg_timeout2( &mut self, ms_timeout: u64, ) -> Option)>> { // Bind the readiness result to a `let` so the borrowed `readable()` future temporary is dropped // at the `;` (releasing `&self.stream`) BEFORE `recv_msg()` takes `&mut self` in an arm. let ready = timeout(ms_timeout, self.stream.readable()).await; match ready { Err(_) => None, // no frame started: clean boundary, caller re-checks `stop` Ok(Err(e)) => Some(Err(e.into())), Ok(Ok(())) => match timeout(ms_timeout, self.recv_msg()).await { Ok(res) => Some(res), // Deadline hit inside recv_msg. Distinguish a spurious readable() with nothing actually // consumed (safe to re-poll -> None) from a genuine mid-frame stall after some bytes // were read (unresumable -> hard error). Err(_) if self.consumed => Some(Err(anyhow::anyhow!( "drm: frame body stalled past {ms_timeout}ms after first byte; closing" ))), Err(_) => None, }, } } /// Send a raw length-prefixed body (cursor pixels, CPU-fallback BGRA). Parity with /// `ConnectionTmpl::send_raw`, over the same manual framing (never carries an fd). pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { drm_send_frame(&self.stream, &data, None).await } /// Receive a raw length-prefixed body. Parity with `ConnectionTmpl::next_raw`. A raw body never /// carries an fd; a stray fd (protocol desync) is collected by `drm_read_full` and dropped/closed. pub async fn next_raw(&mut self) -> ResultType { // next_raw is not called through recv_msg_timeout2, so its progress flag is unused; pass the // field for signature parity (recv_msg clears it before its own reads). let mut prefix = [0u8; 4]; if drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed) .await? .is_some() { log::warn!("drm: unexpected fd on a raw-body frame; dropping"); } let len = u32::from_be_bytes(prefix) as usize; if len > MAX_DRM_RAW_BYTES { bail!("drm: raw body length {len} exceeds cap {MAX_DRM_RAW_BYTES}"); } let mut out = bytes::BytesMut::new(); out.resize(len, 0); drm_read_full(&self.stream, &mut out[..], false, &mut self.consumed).await?; Ok(out) } } #[tokio::main(flavor = "current_thread")] pub async fn get_config(name: &str) -> ResultType> { get_config_async(name, 1_000).await } async fn get_config_async(name: &str, ms_timeout: u64) -> ResultType> { let mut c = connect(ms_timeout, "").await?; c.send(&Data::Config((name.to_owned(), None))).await?; if let Some(Data::Config((name2, value))) = c.next_timeout(ms_timeout).await? { if name == name2 { return Ok(value); } } return Ok(None); } pub async fn set_config_async(name: &str, value: String) -> ResultType<()> { let mut c = connect(1000, "").await?; c.send_config(name, value).await?; Ok(()) } #[tokio::main(flavor = "current_thread")] pub async fn set_data(data: &Data) -> ResultType<()> { set_data_async(data).await } async fn set_data_async(data: &Data) -> ResultType<()> { let mut c = connect(1000, "").await?; c.send(data).await?; Ok(()) } #[tokio::main(flavor = "current_thread")] pub async fn set_config(name: &str, value: String) -> ResultType<()> { set_config_async(name, value).await } pub fn update_temporary_password() -> ResultType<()> { set_config("temporary-password", "".to_owned()) } fn apply_permanent_password_storage_and_salt_payload(payload: Option<&str>) -> ResultType<()> { let Some(payload) = payload else { return Ok(()); }; let Some((storage, salt)) = payload.split_once('\n') else { bail!("Invalid permanent-password-storage-and-salt payload"); }; Config::set_permanent_password_storage_for_sync(storage, salt)?; Ok(()) } pub fn sync_permanent_password_storage_from_daemon() -> ResultType<()> { let v = get_config("permanent-password-storage-and-salt")?; apply_permanent_password_storage_and_salt_payload(v.as_deref()) } async fn sync_permanent_password_storage_from_daemon_async() -> ResultType<()> { let ms_timeout = 1_000; let v = get_config_async("permanent-password-storage-and-salt", ms_timeout).await?; apply_permanent_password_storage_and_salt_payload(v.as_deref()) } pub fn is_permanent_password_set() -> bool { match get_config("permanent-password-set") { Ok(Some(v)) => { let v = v.trim(); return v == "Y"; } Ok(None) => { // No response/value (timeout). } Err(_) => { // Connection error. } } log::warn!("Failed to query permanent password state from daemon"); false } pub fn is_permanent_password_preset() -> bool { if let Ok(Some(v)) = get_config("permanent-password-is-preset") { let v = v.trim(); return v == "Y"; } false } pub fn get_fingerprint() -> String { get_config("fingerprint") .unwrap_or_default() .unwrap_or_default() } pub fn set_permanent_password(v: String) -> ResultType<()> { if Config::is_disable_change_permanent_password() { bail!("Changing permanent password is disabled"); } if set_permanent_password_with_ack(v)? { Ok(()) } else { bail!("Changing permanent password was rejected by daemon"); } } #[tokio::main(flavor = "current_thread")] pub async fn set_permanent_password_with_ack(v: String) -> ResultType { set_permanent_password_with_ack_async(v).await } async fn set_permanent_password_with_ack_async(v: String) -> ResultType { // The daemon ACK/NACK is expected quickly since it applies the config in-process. let ms_timeout = 1_000; let mut c = connect(ms_timeout, "").await?; c.send_config("permanent-password", v).await?; if let Some(Data::Config((name2, Some(v)))) = c.next_timeout(ms_timeout).await? { if name2 == "permanent-password" { let v = v.trim(); let ok = v == "Y"; if ok { // Ensure the hashed permanent password storage is written to the user config file. // This sync must not affect the daemon ACK outcome. if let Err(err) = sync_permanent_password_storage_from_daemon_async().await { log::warn!("Failed to sync permanent password storage from daemon: {err}"); } } return Ok(ok); } } Ok(false) } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn set_unlock_pin(v: String, translate: bool) -> ResultType<()> { let v = v.trim().to_owned(); let min_len = 4; let max_len = crate::ui_interface::max_encrypt_len(); let len = v.chars().count(); if !v.is_empty() { if len < min_len { let err = if translate { crate::lang::translate( "Requires at least {".to_string() + &format!("{min_len}") + "} characters", ) } else { // Sometimes, translated can't show normally in command line format!("Requires at least {} characters", min_len) }; bail!(err); } if len > max_len { bail!("No more than {max_len} characters"); } } Config::set_unlock_pin(&v); set_config("unlock-pin", v) } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn get_unlock_pin() -> String { if let Ok(Some(v)) = get_config("unlock-pin") { Config::set_unlock_pin(&v); v } else { Config::get_unlock_pin() } } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn get_trusted_devices() -> String { if let Ok(Some(v)) = get_config("trusted-devices") { v } else { Config::get_trusted_devices_json() } } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn remove_trusted_devices(hwids: Vec) { Config::remove_trusted_devices(&hwids); allow_err!(set_data(&Data::RemoveTrustedDevices(hwids))); } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn clear_trusted_devices() { Config::clear_trusted_devices(); allow_err!(set_data(&Data::ClearTrustedDevices)); } pub fn get_id() -> String { // An empty id may come from a process that took over the main IPC with a // config scope that has no id yet (e.g. a user GUI that became the server // while the installed service was restarting). Treat it as no answer, // otherwise the empty id is adopted below and wipes the local one. if let Ok(Some(v)) = get_config("id") { if !v.is_empty() { // update salt also, so that next time reinstallation not causing first-time auto-login failure if let Ok(Some(v2)) = get_config("salt") { Config::set_salt(&v2); } if v != Config::get_id() { Config::set_key_confirmed(false); Config::set_id(&v); } return v; } } Config::get_id() } pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec) { if let Ok(Some(v)) = get_config_async("rendezvous_server", ms_timeout).await { let mut urls = v.split(","); let a = urls.next().unwrap_or_default().to_owned(); let b: Vec = urls.map(|x| x.to_owned()).collect(); (a, b) } else { ( Config::get_rendezvous_server(), Config::get_rendezvous_servers(), ) } } async fn get_options_(ms_timeout: u64) -> ResultType> { let mut c = connect(ms_timeout, "").await?; c.send(&Data::Options(None)).await?; if let Some(Data::Options(Some(value))) = c.next_timeout(ms_timeout).await? { Config::set_options(value.clone()); Ok(value) } else { Ok(Config::get_options()) } } pub async fn get_options_async() -> HashMap { get_options_(1000).await.unwrap_or(Config::get_options()) } #[tokio::main(flavor = "current_thread")] pub async fn get_options() -> HashMap { get_options_async().await } pub async fn get_option_async(key: &str) -> String { if let Some(v) = get_options_async().await.get(key) { v.clone() } else { "".to_owned() } } pub fn set_option(key: &str, value: &str) { let mut options = get_options(); if value.is_empty() { options.remove(key); } else { options.insert(key.to_owned(), value.to_owned()); } set_options(options).ok(); } #[tokio::main(flavor = "current_thread")] pub async fn set_options(value: HashMap) -> ResultType<()> { let _nat = CheckTestNatType::new(); if let Ok(mut c) = connect(1000, "").await { c.send(&Data::Options(Some(value.clone()))).await?; // do not put below before connect, because we need to check should_exit c.next_timeout(1000).await.ok(); } Config::set_options(value); Ok(()) } #[inline] async fn get_nat_type_(ms_timeout: u64) -> ResultType { let mut c = connect(ms_timeout, "").await?; c.send(&Data::NatType(None)).await?; if let Some(Data::NatType(Some(value))) = c.next_timeout(ms_timeout).await? { Config::set_nat_type(value); Ok(value) } else { Ok(Config::get_nat_type()) } } pub async fn get_nat_type(ms_timeout: u64) -> i32 { get_nat_type_(ms_timeout) .await .unwrap_or(Config::get_nat_type()) } pub async fn get_rendezvous_servers(ms_timeout: u64) -> Vec { if let Ok(Some(v)) = get_config_async("rendezvous_servers", ms_timeout).await { return v.split(',').map(|x| x.to_owned()).collect(); } return Config::get_rendezvous_servers(); } #[inline] async fn get_socks_(ms_timeout: u64) -> ResultType> { let mut c = connect(ms_timeout, "").await?; c.send(&Data::Socks(None)).await?; if let Some(Data::Socks(value)) = c.next_timeout(ms_timeout).await? { Config::set_socks(value.clone()); Ok(value) } else { Ok(Config::get_socks()) } } pub async fn get_socks_async(ms_timeout: u64) -> Option { get_socks_(ms_timeout).await.unwrap_or(Config::get_socks()) } #[tokio::main(flavor = "current_thread")] pub async fn get_socks() -> Option { get_socks_async(1_000).await } #[tokio::main(flavor = "current_thread")] pub async fn set_socks(value: config::Socks5Server) -> ResultType<()> { let _nat = CheckTestNatType::new(); Config::set_socks(if value.proxy.is_empty() { None } else { Some(value.clone()) }); connect(1_000, "") .await? .send(&Data::Socks(Some(value))) .await?; Ok(()) } async fn get_socks_ws_(ms_timeout: u64) -> ResultType<(Option, String)> { let mut c = connect(ms_timeout, "").await?; c.send(&Data::SocksWs(None)).await?; if let Some(Data::SocksWs(Some(value))) = c.next_timeout(ms_timeout).await? { Config::set_socks(value.0.clone()); Config::set_option(OPTION_ALLOW_WEBSOCKET.to_string(), value.1.clone()); Ok(*value) } else { Ok(( Config::get_socks(), Config::get_option(OPTION_ALLOW_WEBSOCKET), )) } } #[tokio::main(flavor = "current_thread")] pub async fn get_socks_ws() -> (Option, String) { get_socks_ws_(1_000).await.unwrap_or(( Config::get_socks(), Config::get_option(OPTION_ALLOW_WEBSOCKET), )) } pub fn get_proxy_status() -> bool { Config::get_socks().is_some() } #[tokio::main(flavor = "current_thread")] pub async fn test_rendezvous_server() -> ResultType<()> { let mut c = connect(1000, "").await?; c.send(&Data::TestRendezvousServer).await?; Ok(()) } #[tokio::main(flavor = "current_thread")] pub async fn notify_deployed() -> ResultType<()> { let mut c = connect(1000, "").await?; c.send(&Data::Deployed).await?; Ok(()) } #[tokio::main(flavor = "current_thread")] pub async fn send_url_scheme(url: String) -> ResultType<()> { connect(1_000, "_url") .await? .send(&Data::UrlLink(url)) .await?; Ok(()) } // Emit `close` events to ipc. pub fn close_all_instances() -> ResultType { match crate::ipc::send_url_scheme(IPC_ACTION_CLOSE.to_owned()) { Ok(_) => Ok(true), Err(err) => Err(err), } } #[cfg(windows)] #[tokio::main(flavor = "current_thread")] pub async fn connect_to_user_session(usid: Option) -> ResultType<()> { let mut stream = crate::ipc::connect_service(1000).await?; timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??; Ok(()) } #[tokio::main(flavor = "current_thread")] pub async fn notify_server_to_check_hwcodec() -> ResultType<()> { connect(1_000, "").await?.send(&&Data::CheckHwcodec).await?; Ok(()) } #[cfg(target_os = "windows")] pub async fn get_port_forward_session_count(ms_timeout: u64) -> ResultType { let mut c = connect(ms_timeout, "").await?; c.send(&Data::PortForwardSessionCount(None)).await?; if let Some(Data::PortForwardSessionCount(Some(count))) = c.next_timeout(ms_timeout).await? { return Ok(count); } bail!("Failed to get port forward session count"); } #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] pub async fn get_hwcodec_config_from_server() -> ResultType<()> { if !scrap::codec::enable_hwcodec_option() || scrap::hwcodec::HwCodecConfig::already_set() { return Ok(()); } let mut c = connect(50, "").await?; c.send(&Data::HwCodecConfig(None)).await?; if let Some(Data::HwCodecConfig(v)) = c.next_timeout(50).await? { match v { Some(v) => { scrap::hwcodec::HwCodecConfig::set(v); return Ok(()); } None => { bail!("hwcodec config is none"); } } } bail!("failed to get hwcodec config"); } #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn client_get_hwcodec_config_thread(wait_sec: u64) { static ONCE: std::sync::Once = std::sync::Once::new(); if !crate::platform::is_installed() || !scrap::codec::enable_hwcodec_option() || scrap::hwcodec::HwCodecConfig::already_set() { return; } ONCE.call_once(move || { std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(1)); let mut intervals: Vec = vec![wait_sec, 3, 3, 6, 9]; for i in intervals.drain(..) { if i > 0 { std::thread::sleep(std::time::Duration::from_secs(i)); } if get_hwcodec_config_from_server().is_ok() { break; } } }); }); } #[cfg(feature = "hwcodec")] #[tokio::main(flavor = "current_thread")] pub async fn hwcodec_process() { let s = scrap::hwcodec::check_available_hwcodec(); for _ in 0..5 { match crate::ipc::connect(1000, "").await { Ok(mut conn) => { match conn .send(&crate::ipc::Data::HwCodecConfig(Some(s.clone()))) .await { Ok(()) => { log::info!("send ok"); break; } Err(e) => { log::error!("send failed: {e:?}"); } } } Err(e) => { log::error!("connect failed: {e:?}"); } } std::thread::sleep(std::time::Duration::from_secs(1)); } } #[tokio::main(flavor = "current_thread")] pub async fn get_wayland_screencast_restore_token(key: String) -> ResultType { let v = handle_wayland_screencast_restore_token(key, "get".to_owned()).await?; Ok(v.unwrap_or_default()) } #[tokio::main(flavor = "current_thread")] pub async fn clear_wayland_screencast_restore_token(key: String) -> ResultType { if let Some(v) = handle_wayland_screencast_restore_token(key, "clear".to_owned()).await? { return Ok(v.is_empty()); } return Ok(false); } #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) ))] #[tokio::main(flavor = "current_thread")] pub async fn update_controlling_session_count(count: usize) -> ResultType<()> { let mut c = connect(1000, "").await?; c.send(&Data::ControllingSessionCount(count)).await?; Ok(()) } #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn get_terminal_session_count() -> ResultType { let timeout_ms = 1_000; let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; let candidate_uids = terminal_count_candidate_uids(effective_uid); let mut last_err: Option = None; for candidate_uid in candidate_uids { let socket_path = Config::ipc_path_for_uid(candidate_uid, ""); let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path)) .await .map_err(|err| { anyhow::anyhow!( "Timeout connecting to terminal ipc at {}: {}", socket_path, err ) }); let connection = match connect_result { Ok(Ok(connection)) => connection, Ok(Err(err)) => { last_err = Some(anyhow::anyhow!( "Failed to connect to terminal ipc at {}: {}", socket_path, err )); continue; } Err(err) => { last_err = Some(err); continue; } }; let mut ipc_conn = ConnectionTmpl::new(connection); if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await { last_err = Some(anyhow::anyhow!( "Failed to request terminal session count via ipc at {}: {}", socket_path, err )); continue; } match ipc_conn.next_timeout(timeout_ms).await { Ok(Some(Data::TerminalSessionCount(session_count))) => { return Ok(session_count); } Ok(None) => { last_err = Some(anyhow::anyhow!( "Invalid response when requesting terminal session count via ipc at {}", socket_path )); } Ok(other) => { last_err = Some(anyhow::anyhow!( "Unexpected response when requesting terminal session count via ipc at {}: {:?}", socket_path, other.map(|v| std::mem::discriminant(&v)) )); } Err(err) => { last_err = Some(anyhow::anyhow!( "Failed to read terminal session count via ipc at {}: {}", socket_path, err )); } } } if let Some(err) = last_err { Err(err.into()) } else { Ok(0) } } async fn handle_wayland_screencast_restore_token( key: String, value: String, ) -> ResultType> { let ms_timeout = 1_000; let mut c = connect(ms_timeout, "").await?; c.send(&Data::WaylandScreencastRestoreToken((key, value))) .await?; if let Some(Data::WaylandScreencastRestoreToken((_key, v))) = c.next_timeout(ms_timeout).await? { return Ok(Some(v)); } return Ok(None); } #[tokio::main(flavor = "current_thread")] pub async fn set_install_option(k: String, v: String) -> ResultType<()> { if let Ok(mut c) = connect(1000, "").await { c.send(&&Data::InstallOption(Some((k, v)))).await?; // do not put below before connect, because we need to check should_exit c.next_timeout(1000).await.ok(); } Ok(()) } #[cfg(test)] mod test { use super::*; #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); assert!(std::mem::size_of::() <= 120); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_service_ipc_path_is_shared_across_uids() { assert_eq!( Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE), Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE) ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_ipc_path_differs_by_uid_for_cm() { let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; let other_uid = effective_uid.saturating_add(1); let postfix = "_cm"; // Default connect path targets the current effective uid. assert_eq!( Config::ipc_path(postfix), Config::ipc_path_for_uid(effective_uid, postfix) ); // A different uid yields a different socket path - this is the root cause of the // cross-user regression when root spawns a user process but still connects as uid 0. assert_ne!( Config::ipc_path(postfix), Config::ipc_path_for_uid(other_uid, postfix) ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_uses_active_uid_when_no_server_found() { assert_eq!( select_server_uid_for_user_main_ipc(&[], Some(501), false).unwrap(), 501 ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_uses_single_server_uid() { assert_eq!( select_server_uid_for_user_main_ipc(&[501], None, false).unwrap(), 501 ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_prefers_active_uid_with_multiple_servers() { assert_eq!( select_server_uid_for_user_main_ipc(&[0, 501], Some(501), false).unwrap(), 501 ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_prefers_root_on_wayland_login_screen() { assert_eq!( select_server_uid_for_user_main_ipc(&[0, 501], Some(501), true).unwrap(), 0 ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() { assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); } } // Pure-userspace coverage for the bespoke `_drm` SCM_RIGHTS framing (review 6). The wire format is // hand-rolled (length prefix + an fd bound to the frame's first byte) because `Framed`/`BytesCodec` // cannot carry ancillary data, so it gets direct tests over a socketpair instead of only live runs. #[cfg(all(test, target_os = "linux", feature = "drm"))] mod drm_conn_tests { use super::*; use hbb_common::libc; use hbb_common::tokio::{self, io::AsyncWriteExt}; use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; // A blocking pipe as a probe fd: (read end, write end). Both are CLOEXEC-agnostic OwnedFds. fn pipe() -> (OwnedFd, OwnedFd) { let mut fds = [0 as libc::c_int; 2]; assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } } // Raw sendmsg carrying `fds` in a single SCM_RIGHTS cmsg, used to forge the surplus-fd / MSG_CTRUNC // case the safe API cannot express (it sends at most one). unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { let mut iov = libc::iovec { iov_base: data.as_ptr() as *mut libc::c_void, iov_len: data.len(), }; let fdbytes = fds.len() * std::mem::size_of::(); let space = libc::CMSG_SPACE(fdbytes as u32) as usize; let mut cbuf = vec![0u8; space]; let mut msg: libc::msghdr = std::mem::zeroed(); msg.msg_iov = &mut iov; msg.msg_iovlen = 1; msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; msg.msg_controllen = space as _; let cmsg = libc::CMSG_FIRSTHDR(&msg); (*cmsg).cmsg_level = libc::SOL_SOCKET; (*cmsg).cmsg_type = libc::SCM_RIGHTS; (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); libc::sendmsg(sock, &msg, 0) } // A control message with no fd round-trips intact and reports no ancillary fd. #[tokio::test] async fn roundtrip_msg_no_fd() { let (a, b) = tokio::net::UnixStream::pair().unwrap(); let mut tx = DrmConn::new(a); let mut rx = DrmConn::new(b); tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) .await .unwrap(); let (data, fd) = rx.recv_msg().await.unwrap(); assert!(matches!( data, Data::DrmFrame { width: 1920, height: 1080 } )); assert!(fd.is_none(), "no fd was sent, none must be reported"); } // An fd bound to a frame's first byte crosses via SCM_RIGHTS and refers to the SAME open file: a // byte written into the original write end is readable through the received (dup'd) read end. #[tokio::test] async fn roundtrip_msg_with_fd_identity() { let (a, b) = tokio::net::UnixStream::pair().unwrap(); let mut tx = DrmConn::new(a); let mut rx = DrmConn::new(b); let (rd, wr) = pipe(); tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) .await .unwrap(); let (_data, fd) = rx.recv_msg().await.unwrap(); let recv_fd = fd.expect("an fd was attached, it must be received"); let sentinel = [0xABu8]; assert_eq!( unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, 1 ); let mut got = [0u8; 1]; assert_eq!( unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, 1 ); assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); } // A raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte. #[tokio::test] async fn roundtrip_raw_body() { let (a, b) = tokio::net::UnixStream::pair().unwrap(); let mut tx = DrmConn::new(a); let mut rx = DrmConn::new(b); let body = Bytes::from(vec![7u8; 5000]); tx.send_raw(body.clone()).await.unwrap(); let got = rx.next_raw().await.unwrap(); assert_eq!(&got[..], &body[..]); } // A forged length prefix past the JSON cap is rejected at the prefix, before any body allocation. #[tokio::test] async fn rejects_oversized_length_prefix() { let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); let mut rx = DrmConn::new(b); let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); a.write_all(&bogus).await.unwrap(); let err = rx .recv_msg() .await .err() .expect("a length past the cap must be rejected"); assert!( err.to_string().contains("exceeds cap"), "unexpected error: {err}" ); } // A peer that packs more than one fd into a single SCM_RIGHTS cmsg (the safe API never does) must // not smuggle extra fds into the consumer: drm_recvmsg keeps the FIRST and closes the rest. The // frame otherwise decodes normally and the kept fd is the first one sent. (Two fds fit the control // buffer thanks to cmsg alignment slack, so this exercises the surplus path, not truncation.) #[tokio::test] async fn surplus_fds_keep_only_the_first() { let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); let mut rx = DrmConn::new(b); let (rd, wr) = pipe(); let (rd2, _wr2) = pipe(); let payload = serde_json::to_vec(&Data::DrmFrame { width: 8, height: 8, }) .unwrap(); let prefix = (payload.len() as u32).to_be_bytes(); let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); a.write_all(&payload).await.unwrap(); let (data, fd) = rx.recv_msg().await.unwrap(); assert!(matches!( data, Data::DrmFrame { width: 8, height: 8 } )); let kept = fd.expect("the first surplus fd must be kept"); let sentinel = [0x5Au8]; assert_eq!( unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, 1 ); let mut got = [0u8; 1]; assert_eq!( unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, 1 ); assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); } // Enough fds to overflow the receiver's control buffer and force truncation. drm_recvmsg reads // into a DRM_CMSG_CAP (64-byte) control buffer, which holds up to 12 fds (CMSG_LEN(48)=64); 16 fds // need CMSG_LEN(64)=80 > 64, so the kernel sets MSG_CTRUNC and recv_msg must fail rather than // proceed with silently dropped fd(s). #[tokio::test] async fn rejects_truncated_control_message() { let (a, b) = tokio::net::UnixStream::pair().unwrap(); let mut rx = DrmConn::new(b); let (rd, _wr) = pipe(); let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); let err = rx .recv_msg() .await .err() .expect("a truncated control message must be rejected"); let msg = err.to_string().to_lowercase(); assert!( msg.contains("truncat") || msg.contains("ctrunc"), "unexpected error: {err}" ); } // SO_PEERCRED plumbing: over a socketpair both ends report the creating process euid, so the // producer-auth path (connect_drm requires peer_uid == 0) reads a real uid rather than None. #[test] fn peer_uid_from_fd_reads_socket_peer() { let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); let euid = unsafe { libc::geteuid() }; assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); } // Per-frame _drm re-auth decision (review 3.3): root always passes; a non-root peer passes only // while it still equals the active-session uid; an unknown peer or active session fails closed. #[test] fn drm_peer_authorized_matrix() { // root (uid 0) is always authorized, regardless of the active session (even unknown). assert!(drm_peer_authorized(Some(0), Some(1000))); assert!(drm_peer_authorized(Some(0), None)); // a non-root peer is authorized only while it matches the active-session uid. assert!(drm_peer_authorized(Some(1000), Some(1000))); // a non-root peer whose session is no longer active (switched away) is rejected. assert!(!drm_peer_authorized(Some(1000), Some(1001))); // fail closed when the active session is momentarily unknown (mid session switch). assert!(!drm_peer_authorized(Some(1000), None)); // fail closed when the peer uid could not be determined. assert!(!drm_peer_authorized(None, Some(1000))); assert!(!drm_peer_authorized(None, None)); } // _drm admission bound (review 6): admit strictly below MAX_DRM_CONNS, reject at and above it. // `prev_count` is the live count taken before this connection (what fetch_add returns). #[test] fn drm_conn_admission_bound() { assert!(drm_conn_admitted(0)); assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected } }