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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
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}