gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definitions

⎇ main crates/takana-cli/src/qorpa_wl.rs Rust · 458 lines · 20.4 KB History Who touched what raw

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)
2//! §NO-resuelve 1 y §Orden de trabajo 8.
3//!
4//! ── EL AGUJERO QUE CIERRA ───────────────────────────────────────────────────────────────────────
5//! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El
6//! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie
7//! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1`
8//! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie,
9//! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las
10//! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy.
11//!
12//! ── POR QUÉ LISTA BLANCA Y NO NEGRA ─────────────────────────────────────────────────────────────
13//! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene
14//! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos
15//! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una
16//! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el
17//! compositor, y sin un solo error visible.
18//!
19//! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ───────────────────────────────────────────
20//! El proxy relaya bytes salvo dos mensajes:
21//!   · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se
22//!     reenvía**. El cliente nunca se entera de que existe.
23//!   · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un
24//!     `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra.
25//! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes
26//! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada
27//! sin asociarlos a su mensaje.
28
29use std::collections::HashSet;
30use std::io;
31use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
32use std::os::unix::net::{UnixListener, UnixStream};
33use std::path::{Path, PathBuf};
34use std::sync::{Arc, Mutex};
35
36use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags};
37
38/// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames.
39///
40/// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y
41/// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`,
42/// `idle_inhibit` (que no se apague la pantalla a media partida).
43pub const GLOBALS_PERMITIDOS: &[&str] = &[
44    "wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output",
45    "wl_data_device_manager", "wl_drm",
46    "xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1",
47    "wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1",
48    "wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1",
49    "wp_tearing_control_manager_v1", "wp_content_type_manager_v1",
50    "zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1",
51    "zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1",
52    "zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3",
53    "zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2",
54    "xdg_toplevel_icon_manager_v1",
55];
56
57const OBJ_DISPLAY: u32 = 1;
58const OP_DISPLAY_GET_REGISTRY: u16 = 1;
59const OP_DISPLAY_ERROR: u16 = 0;
60const OP_REGISTRY_GLOBAL: u16 = 0;
61const OP_REGISTRY_BIND: u16 = 0;
62/// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad:
63/// para este cliente ese global no existe.
64const ERR_INVALID_OBJECT: u32 = 1;
65
66/// Estado compartido por las dos direcciones de una conexión.
67#[derive(Default)]
68struct Estado {
69    /// Los `wl_registry` que este cliente creó. Puede haber más de uno.
70    registries: HashSet<u32>,
71    /// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro.
72    ocultos: HashSet<u32>,
73}
74
75pub struct Proxy {
76    /// El socket que se bindea DENTRO de la instancia, en vez del real.
77    pub socket: PathBuf,
78    _dir: PathBuf,
79}
80
81/// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula.
82///
83/// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura
84/// la instancia (`takana qorpa run` bloquea en bwrap).
85pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet<String>) -> io::Result<Proxy> {
86    std::fs::create_dir_all(dir)?;
87    let socket = dir.join("wayland-0");
88    let _ = std::fs::remove_file(&socket);
89    let escucha = UnixListener::bind(&socket)?;
90    let real = socket_real.to_path_buf();
91    let permitidos = Arc::new(permitidos);
92
93    std::thread::spawn(move || {
94        for c in escucha.incoming() {
95            let Ok(cliente) = c else { continue };
96            let real = real.clone();
97            let permitidos = permitidos.clone();
98            std::thread::spawn(move || {
99                if let Err(e) = atender(cliente, &real, permitidos) {
100                    eprintln!("[qorpa-wl] conexión terminada: {e}");
101                }
102            });
103        }
104    });
105    Ok(Proxy { socket, _dir: dir.to_path_buf() })
106}
107
108fn atender(cliente: UnixStream, real: &Path, permitidos: Arc<HashSet<String>>) -> io::Result<()> {
109    let servidor = UnixStream::connect(real)?;
110    let estado = Arc::new(Mutex::new(Estado::default()));
111
112    let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone());
113    let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2));
114    let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos);
115    let _ = cliente.shutdown(std::net::Shutdown::Both);
116    let _ = servidor.shutdown(std::net::Shutdown::Both);
117    let _ = subida.join();
118    r
119}
120
121#[derive(PartialEq, Clone, Copy)]
122enum Sentido { HaciaServidor, HaciaCliente }
123
124fn bombear(
125    de: &UnixStream, a: &UnixStream, sentido: Sentido,
126    estado: Arc<Mutex<Estado>>, permitidos: Arc<HashSet<String>>,
127) -> io::Result<()> {
128    let mut pendiente: Vec<u8> = Vec::new();
129    loop {
130        let (datos, fds) = leer(de)?;
131        if datos.is_empty() && fds.is_empty() {
132            return Ok(()); // el otro extremo cerró
133        }
134        pendiente.extend_from_slice(&datos);
135
136        let mut salida: Vec<u8> = Vec::new();
137        let mut consumido = 0usize;
138        while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) {
139            let msg = &pendiente[consumido..consumido + tam];
140            if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? {
141                salida.extend_from_slice(msg);
142            }
143            consumido += tam;
144        }
145        pendiente.drain(..consumido);
146
147        // Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que
148        // el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno.
149        if !salida.is_empty() || !fds.is_empty() {
150            escribir(a, &salida, &fds)?;
151        }
152    }
153}
154
155/// Devuelve `true` si el mensaje se reenvía.
156fn filtrar(
157    msg: &[u8], obj: u32, op: u16, sentido: Sentido,
158    estado: &Arc<Mutex<Estado>>, permitidos: &HashSet<String>, otro: &UnixStream,
159) -> io::Result<bool> {
160    let cuerpo = &msg[8..];
161    match sentido {
162        Sentido::HaciaServidor => {
163            if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY {
164                if let Some(id) = leer_u32(cuerpo, 0) {
165                    estado.lock().unwrap().registries.insert(id);
166                }
167                return Ok(true);
168            }
169            let es_registry = estado.lock().unwrap().registries.contains(&obj);
170            if es_registry && op == OP_REGISTRY_BIND {
171                if let Some(name) = leer_u32(cuerpo, 0) {
172                    if estado.lock().unwrap().ocultos.contains(&name) {
173                        // Bindear un global que nunca se anunció es un intento de saltarse el
174                        // filtro, no un accidente: se corta la conexión diciendo por qué.
175                        let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT,
176                            "qorpa: ese global no existe para esta instancia"), &[]);
177                        return Err(io::Error::other("bind a un global filtrado"));
178                    }
179                }
180            }
181            Ok(true)
182        }
183        Sentido::HaciaCliente => {
184            let es_registry = estado.lock().unwrap().registries.contains(&obj);
185            if es_registry && op == OP_REGISTRY_GLOBAL {
186                let name = leer_u32(cuerpo, 0);
187                let interface = leer_str(cuerpo, 4);
188                if let (Some(name), Some(iface)) = (name, interface) {
189                    if !permitidos.contains(&iface) {
190                        estado.lock().unwrap().ocultos.insert(name);
191                        return Ok(false); // el cliente nunca se entera
192                    }
193                }
194            }
195            Ok(true)
196        }
197    }
198}
199
200// ── el cable ────────────────────────────────────────────────────────────────────────────────────
201
202/// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits
203/// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero.
204fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> {
205    if b.len() < 8 {
206        return None;
207    }
208    let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?);
209    let w = u32::from_ne_bytes(b[4..8].try_into().ok()?);
210    let tam = (w >> 16) as usize;
211    let op = (w & 0xffff) as u16;
212    // Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el
213    // proxy en un bucle, así que se corta.
214    if tam < 8 || b.len() < tam {
215        return None;
216    }
217    Some((obj, op, tam))
218}
219
220fn leer_u32(cuerpo: &[u8], off: usize) -> Option<u32> {
221    cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap()))
222}
223
224/// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes.
225fn leer_str(cuerpo: &[u8], off: usize) -> Option<String> {
226    let len = leer_u32(cuerpo, off)? as usize;
227    if len == 0 {
228        return Some(String::new());
229    }
230    let ini = off + 4;
231    let txt = cuerpo.get(ini..ini + len - 1)?;
232    Some(String::from_utf8_lossy(txt).into_owned())
233}
234
235fn escribir_str(out: &mut Vec<u8>, s: &str) {
236    let bytes = s.as_bytes();
237    let len = bytes.len() + 1;
238    out.extend_from_slice(&(len as u32).to_ne_bytes());
239    out.extend_from_slice(bytes);
240    out.push(0);
241    while out.len() % 4 != 0 {
242        out.push(0);
243    }
244}
245
246fn error_display(obj: u32, code: u32, msg: &str) -> Vec<u8> {
247    let mut cuerpo = Vec::new();
248    cuerpo.extend_from_slice(&obj.to_ne_bytes());
249    cuerpo.extend_from_slice(&code.to_ne_bytes());
250    escribir_str(&mut cuerpo, msg);
251    let tam = 8 + cuerpo.len();
252    let mut out = Vec::with_capacity(tam);
253    out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes());
254    out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes());
255    out.extend_from_slice(&cuerpo);
256    out
257}
258
259fn leer(s: &UnixStream) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
260    let mut buf = [0u8; 8192];
261    let mut espacio = nix::cmsg_space!([RawFd; 32]);
262    let mut iov = [io::IoSliceMut::new(&mut buf)];
263    let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty())
264        .map_err(io::Error::from)?;
265    let mut fds = Vec::new();
266    for c in r.cmsgs().map_err(io::Error::from)? {
267        if let ControlMessageOwned::ScmRights(rs) = c {
268            for fd in rs {
269                fds.push(unsafe { OwnedFd::from_raw_fd(fd) });
270            }
271        }
272    }
273    let n = r.bytes;
274    Ok((buf[..n].to_vec(), fds))
275}
276
277fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> {
278    let crudos: Vec<RawFd> = fds.iter().map(|f| f.as_raw_fd()).collect();
279    let cmsgs: Vec<ControlMessage> = if crudos.is_empty() {
280        vec![]
281    } else {
282        vec![ControlMessage::ScmRights(&crudos)]
283    };
284    // Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir,
285    // y los fds viajan sólo con el primer envío para no duplicarlos.
286    let mut off = 0;
287    loop {
288        let resto = &datos[off..];
289        let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })];
290        let usar = if off == 0 { &cmsgs[..] } else { &[][..] };
291        let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None)
292            .map_err(io::Error::from)?;
293        off += n;
294        if off >= datos.len() {
295            return Ok(());
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::io::{Read, Write};
304
305    fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec<u8> {
306        let tam = 8 + cuerpo.len();
307        let mut v = Vec::with_capacity(tam);
308        v.extend_from_slice(&obj.to_ne_bytes());
309        v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes());
310        v.extend_from_slice(cuerpo);
311        v
312    }
313
314    fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec<u8> {
315        let mut c = Vec::new();
316        c.extend_from_slice(&name.to_ne_bytes());
317        escribir_str(&mut c, iface);
318        c.extend_from_slice(&version.to_ne_bytes());
319        msg(registry, OP_REGISTRY_GLOBAL, &c)
320    }
321
322    /// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga.
323    fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> {
324        let l = UnixListener::bind(sock).unwrap();
325        std::thread::spawn(move || {
326            let (mut c, _) = l.accept().unwrap();
327            let mut buf = [0u8; 4096];
328            let n = c.read(&mut buf).unwrap();
329            // Lo único que se parsea: el id de registry que pidió el cliente.
330            let (obj, op, _) = cabecera(&buf[..n]).unwrap();
331            assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY));
332            let reg = leer_u32(&buf[8..n], 0).unwrap();
333            let mut out = Vec::new();
334            for (name, iface) in &anuncios {
335                out.extend_from_slice(&global(reg, *name, iface, 1));
336            }
337            c.write_all(&out).unwrap();
338            // Se queda viva para que el proxy no vea EOF antes de tiempo.
339            let _ = c.read(&mut buf);
340        })
341    }
342
343    fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec<String> {
344        let mut v = Vec::new();
345        let mut off = 0;
346        while let Some((obj, op, tam)) = cabecera(&datos[off..]) {
347            if obj == reg && op == OP_REGISTRY_GLOBAL {
348                if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) {
349                    v.push(i);
350                }
351            }
352            off += tam;
353        }
354        v
355    }
356
357    #[test]
358    fn el_cliente_no_se_entera_de_los_globals_peligrosos() {
359        let d = tempfile::tempdir().unwrap();
360        let real = d.path().join("compositor.sock");
361        let h = compositor_falso(&real, vec![
362            (1, "wl_compositor".into()),
363            (2, "zwlr_screencopy_manager_v1".into()),      // captura de pantalla
364            (3, "wl_seat".into()),
365            (4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas
366            (5, "zwlr_data_control_manager_v1".into()),    // lee el portapapeles
367            (6, "xdg_wm_base".into()),
368            (7, "zwlr_layer_shell_v1".into()),             // dibuja encima de todo
369        ]);
370
371        let permitidos: HashSet<String> =
372            GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
373        let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
374
375        let mut c = UnixStream::connect(&p.socket).unwrap();
376        let mut cuerpo = Vec::new();
377        cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry
378        c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
379
380        c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
381        let mut buf = [0u8; 8192];
382        let n = c.read(&mut buf).unwrap();
383        let vistas = interfaces_vistas(&buf[..n], 2);
384
385        assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}");
386        assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}");
387        assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}");
388        for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1",
389                          "zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] {
390            assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}");
391        }
392        assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}");
393        // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
394        // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
395        drop(c);
396        drop(h);
397    }
398
399    #[test]
400    fn bindear_un_global_oculto_corta_la_conexion() {
401        let d = tempfile::tempdir().unwrap();
402        let real = d.path().join("c.sock");
403        let h = compositor_falso(&real, vec![
404            (1, "wl_compositor".into()),
405            (99, "zwlr_screencopy_manager_v1".into()),
406        ]);
407        let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
408        let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
409
410        let mut c = UnixStream::connect(&p.socket).unwrap();
411        let mut cuerpo = Vec::new();
412        cuerpo.extend_from_slice(&2u32.to_ne_bytes());
413        c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
414        c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
415        let mut buf = [0u8; 8192];
416        let _ = c.read(&mut buf).unwrap();
417
418        // Un cliente malicioso adivina el `name` que nunca se le anunció.
419        let mut b = Vec::new();
420        b.extend_from_slice(&99u32.to_ne_bytes());
421        escribir_str(&mut b, "zwlr_screencopy_manager_v1");
422        b.extend_from_slice(&1u32.to_ne_bytes());
423        b.extend_from_slice(&3u32.to_ne_bytes());
424        c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap();
425
426        // Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind
427        // dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar.
428        let n = c.read(&mut buf).unwrap_or(0);
429        let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op))
430            == Some((OBJ_DISPLAY, OP_DISPLAY_ERROR));
431        let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 };
432        assert!(hubo_error || cerro, "el bind al global oculto no fue cortado");
433        // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
434        // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
435        drop(c);
436        drop(h);
437    }
438
439    #[test]
440    fn la_lista_es_BLANCA_no_negra() {
441        // Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una
442        // denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls.
443        let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
444        assert!(!permitidos.contains("zwlr_screencopy_manager_v1"));
445        assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9"));
446        assert!(permitidos.contains("wl_compositor"));
447    }
448
449    #[test]
450    fn el_parser_no_se_cuelga_con_un_stream_corrupto() {
451        // Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy.
452        let mut malo = Vec::new();
453        malo.extend_from_slice(&1u32.to_ne_bytes());
454        malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible
455        assert!(cabecera(&malo).is_none());
456        assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto
457    }
458}