Skip to main content

podbox/
compositor.rs

1//! Host-side Wayland compositor bridge: socket setup, the bidirectional
2//! byte-stream bridge loop, and the firewall. The `bridge_loop` and
3//! `firewall` flow are one tightly-coupled unit; stays above ~300 LOC as a
4//! single cohesive concern (documented exemption, per MODULARIZATION_GUIDE).
5
6use std::collections::VecDeque;
7use std::io::{IoSlice, IoSliceMut};
8use std::os::fd::{AsRawFd, OwnedFd, RawFd};
9use std::os::unix::net::{UnixListener, UnixStream};
10use std::path::Path;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result};
16use nix::sys::socket::{ControlMessage, ControlMessageOwned, MsgFlags, recvmsg, sendmsg};
17
18/// Register SIGTERM/SIGINT handlers that set `shutdown`.
19fn setup_signal_handler(shutdown: Arc<AtomicBool>) -> Result<()> {
20    for sig in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] {
21        signal_hook::flag::register(sig, Arc::clone(&shutdown))?;
22    }
23    Ok(())
24}
25
26use crate::config::Config;
27
28const MAX_CONNECTIONS: usize = 128;
29
30mod firewall;
31
32use firewall::{FirewallState, is_blocked_global, rate_allow};
33
34/// Run the Wayland firewall proxy for a container.
35///
36/// Listens on `$XDG_RUNTIME_DIR/podbox/{name}-wayland.sock`, accepts
37/// connections from the container, bridges each to the host compositor's
38/// Wayland socket, and filters blocked interfaces from `wl_registry::global`
39/// events on the host→client path.
40pub fn run_compositor(config: &Config, name: &str) -> Result<()> {
41    let xdg_runtime = std::env::var("XDG_RUNTIME_DIR")
42        .or_else(|_| {
43            let uid = nix::unistd::getuid().as_raw();
44            Ok::<_, std::env::VarError>(format!("/run/user/{uid}"))
45        })
46        .context("XDG_RUNTIME_DIR not set")?;
47
48    let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
49    let host_socket = Path::new(&xdg_runtime).join(&wayland_display);
50
51    if !host_socket.exists() {
52        anyhow::bail!(
53            "Host Wayland socket not found at {} (WAYLAND_DISPLAY={})",
54            host_socket.display(),
55            wayland_display
56        );
57    }
58
59    let socket_path = Path::new(&xdg_runtime)
60        .join("podbox")
61        .join(format!("{name}-wayland.sock"));
62
63    let _ = std::fs::remove_file(&socket_path);
64    std::fs::create_dir_all(socket_path.parent().context("socket path has no parent")?)?;
65
66    let shutdown = Arc::new(AtomicBool::new(false));
67    setup_signal_handler(Arc::clone(&shutdown))?;
68
69    let listener = UnixListener::bind(&socket_path).with_context(|| {
70        format!(
71            "Failed to bind Wayland proxy socket at {}",
72            socket_path.display()
73        )
74    })?;
75    // Non-blocking + periodic tick so SIGTERM/SIGINT ends the accept loop
76    // promptly instead of blocking in accept(2) until systemd's
77    // TimeoutStopSec SIGKILL (90s stall on every container stop).
78    listener.set_nonblocking(true)?;
79
80    let blocked = config.wayland.blocked_interfaces.clone();
81
82    let mut connections = 0;
83    loop {
84        if shutdown.load(Ordering::Relaxed) || connections >= MAX_CONNECTIONS {
85            break;
86        }
87
88        let stream = match listener.accept() {
89            Ok((s, _)) => s,
90            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
91                std::thread::sleep(Duration::from_millis(200));
92                continue;
93            }
94            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
95            Err(e) => {
96                tracing::error!("compositor: accept failed: {e}");
97                break;
98            }
99        };
100        connections += 1;
101
102        stream.set_nonblocking(false)?;
103
104        let host_conn = match UnixStream::connect(&host_socket) {
105            Ok(s) => s,
106            Err(e) => {
107                tracing::error!("compositor: failed to connect to host Wayland socket: {e}");
108                continue;
109            }
110        };
111
112        let state = Arc::new(Mutex::new(FirewallState::new(blocked.clone())));
113        let done = Arc::new(AtomicBool::new(false));
114
115        let client_clone = stream.try_clone()?;
116        let host_clone = host_conn.try_clone()?;
117        let state_c2h = Arc::clone(&state);
118        let done_c2h = Arc::clone(&done);
119
120        std::thread::spawn(move || {
121            if let Err(e) = bridge_loop(stream, host_clone, state_c2h, &done_c2h, true) {
122                tracing::error!("compositor: client→host bridge error: {e}");
123            }
124            done_c2h.store(true, Ordering::Relaxed);
125        });
126
127        let state_h2c = state;
128        let done_h2c = done;
129
130        std::thread::spawn(move || {
131            if let Err(e) = bridge_loop(host_conn, client_clone, state_h2c, &done_h2c, false) {
132                tracing::error!("compositor: host→client bridge error: {e}");
133            }
134            done_h2c.store(true, Ordering::Relaxed);
135        });
136    }
137
138    Ok(())
139}
140
141/// Bidirectional byte-stream bridge between two Unix sockets.
142///
143/// For the host→client direction, `is_client_to_host = false`, and the
144/// bridge intercepts `wl_registry::global` events (opcode 0, string
145/// payload at offset 12) to filter interfaces on the blocklist.
146///
147/// File descriptors received via `SCM_RIGHTS` are attributed per message:
148/// each read's fds are keyed to the absolute stream offset at which the
149/// read ended, and attach to the Wayland message whose completion boundary
150/// matches. libwayland transmits an fd in the same datagram as the message
151/// bytes referencing it, so the batch delivered by the completing read
152/// belongs to that message — never to earlier messages completed by the
153/// same or prior reads, and a dropped message only closes the fds it owns.
154fn bridge_loop(
155    in_socket: UnixStream,
156    out_socket: UnixStream,
157    state: Arc<Mutex<FirewallState>>,
158    done: &AtomicBool,
159    is_client_to_host: bool,
160) -> Result<()> {
161    let mut read_buf = [0u8; 16384];
162    let mut cmsg_buffer = vec![0u8; 4096];
163    let mut bytes_cache = Vec::with_capacity(32768);
164    // Fds grouped by the absolute stream offset of the read that delivered
165    // them. Batches are consumed (FIFO) by messages as they complete.
166    let mut fd_batches: VecDeque<(usize, Vec<OwnedFd>)> = VecDeque::new();
167    // Absolute stream offset of bytes_cache[0].
168    let mut base_offset: usize = 0;
169
170    // Token-bucket rate limiter for host→client direction.
171    // Protects the guest from slow-client memory exhaustion.
172    let mut bucket: f64 = 10_000.0;
173    let mut last_refill = Instant::now();
174
175    loop {
176        if done.load(Ordering::Relaxed) {
177            break;
178        }
179
180        let msg_bytes = {
181            let mut iov = [IoSliceMut::new(&mut read_buf)];
182            let msg = match recvmsg::<()>(
183                in_socket.as_raw_fd(),
184                &mut iov,
185                Some(&mut cmsg_buffer),
186                MsgFlags::empty(),
187            ) {
188                Ok(m) => m,
189                Err(e) if e == nix::errno::Errno::EINTR => continue,
190                Err(e) => {
191                    done.store(true, Ordering::Relaxed);
192                    let _ = in_socket.shutdown(std::net::Shutdown::Both);
193                    let _ = out_socket.shutdown(std::net::Shutdown::Both);
194                    return Err(e.into());
195                }
196            };
197
198            let bytes = msg.bytes;
199            if bytes == 0 {
200                break;
201            }
202
203            let mut read_fds: Vec<OwnedFd> = Vec::new();
204            if let Ok(cmsgs) = msg.cmsgs() {
205                for cmsg in cmsgs {
206                    if let ControlMessageOwned::ScmRights(fds) = cmsg {
207                        for fd in fds {
208                            read_fds.push(crate::process::adopt_scm_fd(fd));
209                        }
210                    }
211                }
212            }
213
214            // Key this read's fds to where the read ends in the stream:
215            // the message completed exactly there owns them.
216            if !read_fds.is_empty() {
217                fd_batches.push_back((base_offset + bytes_cache.len() + bytes, read_fds));
218            }
219
220            bytes
221        };
222
223        bytes_cache.extend_from_slice(&read_buf[..msg_bytes]);
224
225        // Process complete Wayland messages from the coalesced buffer.
226        let mut consumed = 0;
227        while consumed + 8 <= bytes_cache.len() {
228            let header = &bytes_cache[consumed..consumed + 8];
229            let size_and_opcode = u32::from_ne_bytes(header[4..8].try_into().unwrap());
230            let msg_size = (size_and_opcode >> 16) as usize;
231            let opcode = (size_and_opcode & 0xFFFF) as u16;
232
233            if msg_size < 8 {
234                done.store(true, Ordering::Relaxed);
235                let _ = in_socket.shutdown(std::net::Shutdown::Both);
236                let _ = out_socket.shutdown(std::net::Shutdown::Both);
237                anyhow::bail!("Invalid Wayland message size: {msg_size}");
238            }
239
240            if consumed + msg_size > bytes_cache.len() {
241                break;
242            }
243
244            let message_bytes = &bytes_cache[consumed..consumed + msg_size];
245            let should_drop =
246                !is_client_to_host && is_blocked_global(message_bytes, opcode, &state);
247
248            let message_end_abs = base_offset + consumed + msg_size;
249            let mut msg_fds = take_fd_batches(&mut fd_batches, message_end_abs);
250
251            if should_drop {
252                // Dropping `msg_fds` closes them — correct: they belonged to
253                // the blocked message alone.
254                drop(msg_fds);
255            } else if is_client_to_host || rate_allow(&mut bucket, &mut last_refill) {
256                forward_message(&out_socket, message_bytes, &mut msg_fds)?;
257            } else {
258                drop(fd_batches);
259                done.store(true, Ordering::Relaxed);
260                let _ = in_socket.shutdown(std::net::Shutdown::Both);
261                let _ = out_socket.shutdown(std::net::Shutdown::Both);
262                tracing::warn!("compositor: rate-limited, closing connection");
263                return Ok(());
264            }
265
266            consumed += msg_size;
267        }
268
269        bytes_cache.drain(..consumed);
270        base_offset += consumed;
271    }
272
273    // Signal shutdown to the sibling thread
274    done.store(true, Ordering::Relaxed);
275    let _ = in_socket.shutdown(std::net::Shutdown::Both);
276    let _ = out_socket.shutdown(std::net::Shutdown::Both);
277    Ok(())
278}
279
280/// Take ownership of every fd batch delivered by reads ending at or before
281/// `message_end` (absolute stream offset). These are the fds carried by the
282/// Wayland message completing at `message_end`: libwayland sends an fd in
283/// the same datagram as the message bytes referencing it.
284fn take_fd_batches<T>(fd_batches: &mut VecDeque<(usize, Vec<T>)>, message_end: usize) -> Vec<T> {
285    let mut out = Vec::new();
286    while let Some((read_end, _)) = fd_batches.front() {
287        if *read_end > message_end {
288            break;
289        }
290        let (_, fds) = fd_batches.pop_front().expect("front checked");
291        out.extend(fds);
292    }
293    out
294}
295
296/// Forward a single Wayland message (with any accumulated fds) to the
297/// output socket.
298fn forward_message(
299    out_socket: &UnixStream,
300    message_bytes: &[u8],
301    pending_fds: &mut Vec<OwnedFd>,
302) -> Result<()> {
303    let iov = [IoSlice::new(message_bytes)];
304
305    if pending_fds.is_empty() {
306        sendmsg::<()>(out_socket.as_raw_fd(), &iov, &[], MsgFlags::empty(), None)?;
307    } else {
308        let raw_fds: Vec<RawFd> = pending_fds.iter().map(|f| f.as_raw_fd()).collect();
309        let cmsg = ControlMessage::ScmRights(&raw_fds);
310        sendmsg::<()>(
311            out_socket.as_raw_fd(),
312            &iov,
313            &[cmsg],
314            MsgFlags::empty(),
315            None,
316        )?;
317        pending_fds.clear();
318    }
319
320    Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn make_global(object_id: u32, name: u32, interface: &str, version: u32) -> Vec<u8> {
328        let raw = interface.as_bytes();
329        let str_len = raw.len().checked_add(1).unwrap();
330        let padded_len = str_len.next_multiple_of(4);
331        let msg_size = u32::try_from(8 + 4 + 4 + padded_len + 4).unwrap();
332
333        let mut buf = Vec::with_capacity(msg_size as usize);
334        buf.extend_from_slice(&object_id.to_ne_bytes());
335        buf.extend_from_slice(&(msg_size << 16).to_ne_bytes());
336        buf.extend_from_slice(&name.to_ne_bytes());
337        buf.extend_from_slice(&u32::try_from(str_len).unwrap().to_ne_bytes());
338        buf.extend_from_slice(raw);
339        buf.push(0);
340        while buf.len() < (8 + 4 + 4 + padded_len) {
341            buf.push(0);
342        }
343        buf.extend_from_slice(&version.to_ne_bytes());
344        buf
345    }
346
347    fn make_message(object_id: u32, size: u32, opcode: u16) -> Vec<u8> {
348        let mut buf = Vec::with_capacity(size as usize);
349        buf.extend_from_slice(&object_id.to_ne_bytes());
350        buf.extend_from_slice(&((size << 16) | u32::from(opcode)).to_ne_bytes());
351        while buf.len() < size as usize {
352            buf.push(0);
353        }
354        buf
355    }
356
357    fn blocked_state() -> Mutex<FirewallState> {
358        Mutex::new(FirewallState::new(vec![
359            "zwlr_screencopy_manager_v1".into(),
360            "ext_foreign_toplevel_list_v1".into(),
361        ]))
362    }
363
364    fn empty_state() -> Mutex<FirewallState> {
365        Mutex::new(FirewallState::new(vec![]))
366    }
367
368    #[test]
369    fn blocks_screencopy_interface() {
370        let data = make_global(2, 42, "zwlr_screencopy_manager_v1", 1);
371        assert!(is_blocked_global(&data, 0, &blocked_state()));
372    }
373
374    #[test]
375    fn blocks_foreign_toplevel() {
376        let data = make_global(2, 43, "ext_foreign_toplevel_list_v1", 1);
377        assert!(is_blocked_global(&data, 0, &blocked_state()));
378    }
379
380    #[test]
381    fn allows_safe_interface() {
382        let data = make_global(2, 44, "wl_compositor", 6);
383        assert!(!is_blocked_global(&data, 0, &blocked_state()));
384    }
385
386    #[test]
387    fn allows_wl_shm() {
388        let data = make_global(2, 1, "wl_shm", 1);
389        assert!(!is_blocked_global(&data, 0, &blocked_state()));
390    }
391
392    #[test]
393    fn blocks_nothing_when_empty_blocklist() {
394        let data = make_global(2, 42, "zwlr_screencopy_manager_v1", 1);
395        assert!(!is_blocked_global(&data, 0, &empty_state()));
396    }
397
398    #[test]
399    fn ignores_non_registry_opcode() {
400        let data = make_message(2, 16, 1);
401        assert!(!is_blocked_global(&data, 1, &blocked_state()));
402    }
403
404    #[test]
405    fn ignores_short_payload() {
406        let data = make_message(2, 12, 0);
407        assert!(!is_blocked_global(&data, 0, &blocked_state()));
408    }
409
410    #[test]
411    fn ignores_empty_interface_string() {
412        let mut data = make_message(2, 16, 0);
413        data[12..16].copy_from_slice(&0u32.to_ne_bytes());
414        assert!(!is_blocked_global(&data, 0, &blocked_state()));
415    }
416
417    #[test]
418    fn allows_partial_name_prefix_match() {
419        let data = make_global(2, 42, "zwlr_screencopy", 1);
420        assert!(!is_blocked_global(&data, 0, &blocked_state()));
421    }
422
423    #[test]
424    fn allows_similar_but_not_blocked() {
425        let data = make_global(2, 99, "zwlr_layer_shell_v1", 1);
426        assert!(!is_blocked_global(&data, 0, &blocked_state()));
427    }
428
429    #[test]
430    fn rate_allow_accepts_first_message() {
431        let mut bucket = 10_000.0;
432        let mut last = Instant::now();
433        assert!(rate_allow(&mut bucket, &mut last));
434    }
435
436    #[test]
437    fn rate_allow_drains_bucket() {
438        let mut bucket = 2.0;
439        let mut last = Instant::now();
440        assert!(rate_allow(&mut bucket, &mut last));
441        assert!(rate_allow(&mut bucket, &mut last));
442        assert!(!rate_allow(&mut bucket, &mut last));
443    }
444
445    #[test]
446    fn rate_allow_refills_over_time() {
447        let mut bucket = 0.0;
448        let mut last = Instant::now();
449        assert!(!rate_allow(&mut bucket, &mut last));
450        // Simulate a small delay
451        std::thread::sleep(std::time::Duration::from_millis(1));
452        let mut bucket = 0.0;
453        let mut last_refill = Instant::now();
454        std::thread::sleep(std::time::Duration::from_millis(2));
455        assert!(rate_allow(&mut bucket, &mut last_refill));
456    }
457
458    // ---- FD batch attribution ----
459
460    /// Fake fd tokens: attribution logic is offset-based, so plain integers
461    /// stand in for real descriptors (no drop side effects).
462    fn batches(items: &[(usize, &[u32])]) -> VecDeque<(usize, Vec<u32>)> {
463        items
464            .iter()
465            .map(|&(end, fds)| (end, fds.to_vec()))
466            .collect()
467    }
468
469    #[test]
470    fn fd_batch_attaches_to_completing_message() {
471        // Read ended at 24 — exactly where message [16..24) completes.
472        let mut q = batches(&[(24, &[7, 8])]);
473        let fds = take_fd_batches(&mut q, 24);
474        assert_eq!(fds, vec![7u32, 8]);
475        assert!(q.is_empty());
476    }
477
478    #[test]
479    fn fd_batch_waits_for_its_own_message() {
480        // Review case: message A [0..12) completed by read #1, then read #2
481        // ends at 24 completing B [12..24) and delivering fds. The fds must
482        // ride with B, not with the earlier-processed A.
483        let mut q = batches(&[(24, &[9])]);
484        assert!(take_fd_batches(&mut q, 12).is_empty());
485        assert_eq!(take_fd_batches(&mut q, 24), vec![9u32]);
486    }
487
488    #[test]
489    fn split_message_gets_fds_from_tail_read() {
490        // wl_shm.create_pool split across reads: header in read #1, tail +
491        // fd in read #2 ending at 40. Only when the message completes at 40
492        // do its fds become available.
493        let mut q = batches(&[(40, &[5])]);
494        assert!(take_fd_batches(&mut q, 20).is_empty());
495        assert_eq!(take_fd_batches(&mut q, 40), vec![5u32]);
496    }
497
498    #[test]
499    fn blocked_message_closes_only_its_own_fds() {
500        // Blocked global [0..28) owns the batch from read #1; a later valid
501        // message's batch (read #2, end 60) must survive the drop.
502        let mut q = batches(&[(28, &[3]), (60, &[4])]);
503        let dropped = take_fd_batches(&mut q, 28);
504        assert_eq!(dropped, vec![3u32]); // caller drops these
505        assert_eq!(take_fd_batches(&mut q, 60), vec![4u32]); // survivor intact
506    }
507}