qorpa D11: el proxy de Wayland — el socket deja de ser un cheque en blanco
Paso 8 del ADR 0015, el que el propio ADR llamaba el más valioso, y cierra su §NO-resuelve 1. Pasar el socket de Wayland crudo NO es abrir un caño: es un borde de privilegio. El compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie concedió — screencopy captura la pantalla, virtual_keyboard sintetiza teclas, data_control lee el portapapeles sin que nadie copie, y layer_shell dibuja encima de todo. Una instancia ajena con el socket crudo podía las cuatro, y lo único que teníamos era un aviso. El proxy relaya todo salvo dos mensajes: - `wl_registry.global` del compositor: si el interface no está en la lista, no se reenvía y el cliente nunca se entera de que existe. - `wl_registry.bind` del cliente: si el `name` es uno de los ocultos, se manda un wl_display.error y se corta. Sin esto el filtro sería decorativo — el `name` es un número y se puede adivinar. LISTA BLANCA, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón: el conjunto PELIGROSO de Wayland crece —cada compositor inventa protocolos privilegiados— mientras que el NECESARIO para dibujar una ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el compositor, y sin un solo error visible. La lista incluye a propósito lo que los juegos piden y suele olvidarse: pointer_constraints, relative_pointer, tearing_control, idle_inhibit. Lo que hace esto viable en un fichero: ningún mensaje que se descarta lleva descriptores, así que los fds —que Wayland pasa por SCM_RIGHTS para wl_shm y dmabuf, y sin los cuales no se dibuja nada— se relayan en orden de llegada sin tener que asociarlos a su mensaje. PROBADO SIN COMPOSITOR, con uno falso que anuncia siete globals (tres permitidos y cuatro peligrosos) y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear por número el name que nunca se anunció corta la conexión. Es la clase de prueba que no necesita GPU y responde la pregunta entera. Un test más cubre el `size` menor que la cabecera, que haría avanzar 0 bytes y colgar el proxy en un bucle. El socket crudo sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos. Y si no hay compositor, `run` FALLA en vez de degradar. Las features `socket`/`uio` de nix son aditivas: no cambian lo que compilan los demás crates. 54/54. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
Generated
+1
@@ -549,6 +549,7 @@ dependencies = [
|
||||
"hammer-overlay",
|
||||
"hammer-upgrade",
|
||||
"hex",
|
||||
"nix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
+4
-1
@@ -49,7 +49,10 @@ hex = "0.4"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2"
|
||||
getrandom = "0.2"
|
||||
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] }
|
||||
# `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS —
|
||||
# Wayland pasa descriptores (wl_shm, dmabuf) por el mismo socket, y sin relayarlos no dibuja
|
||||
# nada. Las features de cargo son ADITIVAS: activarlas no cambia lo que compilan los demás.
|
||||
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio"] }
|
||||
tempfile = "3"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -29,6 +29,7 @@ hammer-agent.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
nix.workspace = true
|
||||
base64.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
|
||||
mod alpine_import;
|
||||
mod kernel_cmd;
|
||||
mod qorpa;
|
||||
mod qorpa_wl;
|
||||
mod nix_import;
|
||||
|
||||
const DEFAULT_STORE: &str = "/store";
|
||||
|
||||
@@ -608,6 +608,15 @@ struct Grants {
|
||||
/// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
|
||||
#[serde(default)]
|
||||
network: bool,
|
||||
/// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto.
|
||||
///
|
||||
/// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan
|
||||
/// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar.
|
||||
#[serde(default)]
|
||||
wayland_raw: bool,
|
||||
/// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
wayland_globals: Vec<String>,
|
||||
/// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
|
||||
/// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
|
||||
/// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
|
||||
@@ -733,6 +742,7 @@ impl Default for Grants {
|
||||
Grants {
|
||||
sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
|
||||
network: false, nesting: false, root: true, seal_image: false,
|
||||
wayland_raw: false, wayland_globals: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,7 +853,7 @@ fn recreate(root: &Path, id: &str) -> Result<()> {
|
||||
|
||||
/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
|
||||
/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
|
||||
fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec<String>, Vec<String>) {
|
||||
fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec<String>, Vec<String>) {
|
||||
let mut a: Vec<String> = Vec::new();
|
||||
let mut warn: Vec<String> = Vec::new();
|
||||
// Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
|
||||
@@ -855,13 +865,19 @@ fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec<String>, Vec<String>) {
|
||||
for s in &g.sockets {
|
||||
let (host, dentro) = match s.as_str() {
|
||||
"wayland" => {
|
||||
warn.push(
|
||||
"wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \
|
||||
socket es un borde de privilegio y todavía no hay proxy filtrante \
|
||||
(ADR §NO-resuelve 1)".into(),
|
||||
);
|
||||
let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
|
||||
(format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
|
||||
match wl {
|
||||
Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")),
|
||||
None => {
|
||||
warn.push(
|
||||
"wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \
|
||||
compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \
|
||||
PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito."
|
||||
.into(),
|
||||
);
|
||||
(format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
|
||||
}
|
||||
}
|
||||
}
|
||||
"pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")),
|
||||
"dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")),
|
||||
@@ -1058,7 +1074,34 @@ fn run_instance(
|
||||
None => uid_dentro,
|
||||
};
|
||||
let runtime_dir = format!("/run/user/{uid_corre}");
|
||||
let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir);
|
||||
|
||||
// El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia.
|
||||
// `_proxy` se mantiene en scope a propósito.
|
||||
let mut proxy_sock: Option<PathBuf> = None;
|
||||
let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw {
|
||||
let xdg = std::env::var("XDG_RUNTIME_DIR")
|
||||
.unwrap_or_else(|_| format!("/run/user/{}", uid_corre));
|
||||
let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
|
||||
let real = PathBuf::from(format!("{xdg}/{d}"));
|
||||
if !real.exists() {
|
||||
bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display());
|
||||
}
|
||||
let permitidos: std::collections::HashSet<String> = if inst.grants.wayland_globals.is_empty() {
|
||||
crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect()
|
||||
} else {
|
||||
inst.grants.wayland_globals.iter().cloned().collect()
|
||||
};
|
||||
let n = permitidos.len();
|
||||
let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos)
|
||||
.context("no pude arrancar el proxy de Wayland")?;
|
||||
eprintln!(" wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \
|
||||
para la instancia");
|
||||
proxy_sock = Some(p.socket.clone());
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref());
|
||||
|
||||
// El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
|
||||
// después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
|
||||
@@ -1934,12 +1977,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
|
||||
let (a, w) = grants_to_args(&Grants::default(), "/run/user/0");
|
||||
let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None);
|
||||
assert!(a.contains(&"--unshare-net".to_string()));
|
||||
assert!(w.is_empty());
|
||||
|
||||
let g = Grants { network: true, ..Default::default() };
|
||||
let (a, _) = grants_to_args(&g, "/run/user/0");
|
||||
let (a, _) = grants_to_args(&g, "/run/user/0", None);
|
||||
assert!(!a.contains(&"--unshare-net".to_string()));
|
||||
// El segundo fallo clásico de la primera corrida, según el ADR.
|
||||
assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
|
||||
@@ -1948,7 +1991,7 @@ mod tests {
|
||||
#[test]
|
||||
fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
|
||||
let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
|
||||
let (_, w) = grants_to_args(&g, "/run/user/0");
|
||||
let (_, w) = grants_to_args(&g, "/run/user/0", None);
|
||||
assert_eq!(w.len(), 1);
|
||||
assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)
|
||||
//! §NO-resuelve 1 y §Orden de trabajo 8.
|
||||
//!
|
||||
//! ── EL AGUJERO QUE CIERRA ───────────────────────────────────────────────────────────────────────
|
||||
//! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El
|
||||
//! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie
|
||||
//! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1`
|
||||
//! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie,
|
||||
//! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las
|
||||
//! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy.
|
||||
//!
|
||||
//! ── POR QUÉ LISTA BLANCA Y NO NEGRA ─────────────────────────────────────────────────────────────
|
||||
//! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene
|
||||
//! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos
|
||||
//! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una
|
||||
//! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el
|
||||
//! compositor, y sin un solo error visible.
|
||||
//!
|
||||
//! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ───────────────────────────────────────────
|
||||
//! El proxy relaya bytes salvo dos mensajes:
|
||||
//! · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se
|
||||
//! reenvía**. El cliente nunca se entera de que existe.
|
||||
//! · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un
|
||||
//! `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra.
|
||||
//! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes
|
||||
//! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada
|
||||
//! sin asociarlos a su mensaje.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags};
|
||||
|
||||
/// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames.
|
||||
///
|
||||
/// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y
|
||||
/// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`,
|
||||
/// `idle_inhibit` (que no se apague la pantalla a media partida).
|
||||
pub const GLOBALS_PERMITIDOS: &[&str] = &[
|
||||
"wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output",
|
||||
"wl_data_device_manager", "wl_drm",
|
||||
"xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1",
|
||||
"wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1",
|
||||
"wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1",
|
||||
"wp_tearing_control_manager_v1", "wp_content_type_manager_v1",
|
||||
"zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1",
|
||||
"zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1",
|
||||
"zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3",
|
||||
"zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2",
|
||||
"xdg_toplevel_icon_manager_v1",
|
||||
];
|
||||
|
||||
const OBJ_DISPLAY: u32 = 1;
|
||||
const OP_DISPLAY_GET_REGISTRY: u16 = 1;
|
||||
const OP_DISPLAY_ERROR: u16 = 0;
|
||||
const OP_REGISTRY_GLOBAL: u16 = 0;
|
||||
const OP_REGISTRY_BIND: u16 = 0;
|
||||
/// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad:
|
||||
/// para este cliente ese global no existe.
|
||||
const ERR_INVALID_OBJECT: u32 = 1;
|
||||
|
||||
/// Estado compartido por las dos direcciones de una conexión.
|
||||
#[derive(Default)]
|
||||
struct Estado {
|
||||
/// Los `wl_registry` que este cliente creó. Puede haber más de uno.
|
||||
registries: HashSet<u32>,
|
||||
/// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro.
|
||||
ocultos: HashSet<u32>,
|
||||
}
|
||||
|
||||
pub struct Proxy {
|
||||
/// El socket que se bindea DENTRO de la instancia, en vez del real.
|
||||
pub socket: PathBuf,
|
||||
_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula.
|
||||
///
|
||||
/// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura
|
||||
/// la instancia (`hammer qorpa run` bloquea en bwrap).
|
||||
pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet<String>) -> io::Result<Proxy> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let socket = dir.join("wayland-0");
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
let escucha = UnixListener::bind(&socket)?;
|
||||
let real = socket_real.to_path_buf();
|
||||
let permitidos = Arc::new(permitidos);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for c in escucha.incoming() {
|
||||
let Ok(cliente) = c else { continue };
|
||||
let real = real.clone();
|
||||
let permitidos = permitidos.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = atender(cliente, &real, permitidos) {
|
||||
eprintln!("[qorpa-wl] conexión terminada: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(Proxy { socket, _dir: dir.to_path_buf() })
|
||||
}
|
||||
|
||||
fn atender(cliente: UnixStream, real: &Path, permitidos: Arc<HashSet<String>>) -> io::Result<()> {
|
||||
let servidor = UnixStream::connect(real)?;
|
||||
let estado = Arc::new(Mutex::new(Estado::default()));
|
||||
|
||||
let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone());
|
||||
let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2));
|
||||
let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos);
|
||||
let _ = cliente.shutdown(std::net::Shutdown::Both);
|
||||
let _ = servidor.shutdown(std::net::Shutdown::Both);
|
||||
let _ = subida.join();
|
||||
r
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Sentido { HaciaServidor, HaciaCliente }
|
||||
|
||||
fn bombear(
|
||||
de: &UnixStream, a: &UnixStream, sentido: Sentido,
|
||||
estado: Arc<Mutex<Estado>>, permitidos: Arc<HashSet<String>>,
|
||||
) -> io::Result<()> {
|
||||
let mut pendiente: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
let (datos, fds) = leer(de)?;
|
||||
if datos.is_empty() && fds.is_empty() {
|
||||
return Ok(()); // el otro extremo cerró
|
||||
}
|
||||
pendiente.extend_from_slice(&datos);
|
||||
|
||||
let mut salida: Vec<u8> = Vec::new();
|
||||
let mut consumido = 0usize;
|
||||
while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) {
|
||||
let msg = &pendiente[consumido..consumido + tam];
|
||||
if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? {
|
||||
salida.extend_from_slice(msg);
|
||||
}
|
||||
consumido += tam;
|
||||
}
|
||||
pendiente.drain(..consumido);
|
||||
|
||||
// Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que
|
||||
// el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno.
|
||||
if !salida.is_empty() || !fds.is_empty() {
|
||||
escribir(a, &salida, &fds)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve `true` si el mensaje se reenvía.
|
||||
fn filtrar(
|
||||
msg: &[u8], obj: u32, op: u16, sentido: Sentido,
|
||||
estado: &Arc<Mutex<Estado>>, permitidos: &HashSet<String>, otro: &UnixStream,
|
||||
) -> io::Result<bool> {
|
||||
let cuerpo = &msg[8..];
|
||||
match sentido {
|
||||
Sentido::HaciaServidor => {
|
||||
if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY {
|
||||
if let Some(id) = leer_u32(cuerpo, 0) {
|
||||
estado.lock().unwrap().registries.insert(id);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let es_registry = estado.lock().unwrap().registries.contains(&obj);
|
||||
if es_registry && op == OP_REGISTRY_BIND {
|
||||
if let Some(name) = leer_u32(cuerpo, 0) {
|
||||
if estado.lock().unwrap().ocultos.contains(&name) {
|
||||
// Bindear un global que nunca se anunció es un intento de saltarse el
|
||||
// filtro, no un accidente: se corta la conexión diciendo por qué.
|
||||
let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT,
|
||||
"qorpa: ese global no existe para esta instancia"), &[]);
|
||||
return Err(io::Error::other("bind a un global filtrado"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Sentido::HaciaCliente => {
|
||||
let es_registry = estado.lock().unwrap().registries.contains(&obj);
|
||||
if es_registry && op == OP_REGISTRY_GLOBAL {
|
||||
let name = leer_u32(cuerpo, 0);
|
||||
let interface = leer_str(cuerpo, 4);
|
||||
if let (Some(name), Some(iface)) = (name, interface) {
|
||||
if !permitidos.contains(&iface) {
|
||||
estado.lock().unwrap().ocultos.insert(name);
|
||||
return Ok(false); // el cliente nunca se entera
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── el cable ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits
|
||||
/// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero.
|
||||
fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> {
|
||||
if b.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?);
|
||||
let w = u32::from_ne_bytes(b[4..8].try_into().ok()?);
|
||||
let tam = (w >> 16) as usize;
|
||||
let op = (w & 0xffff) as u16;
|
||||
// Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el
|
||||
// proxy en un bucle, así que se corta.
|
||||
if tam < 8 || b.len() < tam {
|
||||
return None;
|
||||
}
|
||||
Some((obj, op, tam))
|
||||
}
|
||||
|
||||
fn leer_u32(cuerpo: &[u8], off: usize) -> Option<u32> {
|
||||
cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes.
|
||||
fn leer_str(cuerpo: &[u8], off: usize) -> Option<String> {
|
||||
let len = leer_u32(cuerpo, off)? as usize;
|
||||
if len == 0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
let ini = off + 4;
|
||||
let txt = cuerpo.get(ini..ini + len - 1)?;
|
||||
Some(String::from_utf8_lossy(txt).into_owned())
|
||||
}
|
||||
|
||||
fn escribir_str(out: &mut Vec<u8>, s: &str) {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len() + 1;
|
||||
out.extend_from_slice(&(len as u32).to_ne_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
out.push(0);
|
||||
while out.len() % 4 != 0 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn error_display(obj: u32, code: u32, msg: &str) -> Vec<u8> {
|
||||
let mut cuerpo = Vec::new();
|
||||
cuerpo.extend_from_slice(&obj.to_ne_bytes());
|
||||
cuerpo.extend_from_slice(&code.to_ne_bytes());
|
||||
escribir_str(&mut cuerpo, msg);
|
||||
let tam = 8 + cuerpo.len();
|
||||
let mut out = Vec::with_capacity(tam);
|
||||
out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes());
|
||||
out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes());
|
||||
out.extend_from_slice(&cuerpo);
|
||||
out
|
||||
}
|
||||
|
||||
fn leer(s: &UnixStream) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut espacio = nix::cmsg_space!([RawFd; 32]);
|
||||
let mut iov = [io::IoSliceMut::new(&mut buf)];
|
||||
let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty())
|
||||
.map_err(io::Error::from)?;
|
||||
let mut fds = Vec::new();
|
||||
for c in r.cmsgs().map_err(io::Error::from)? {
|
||||
if let ControlMessageOwned::ScmRights(rs) = c {
|
||||
for fd in rs {
|
||||
fds.push(unsafe { OwnedFd::from_raw_fd(fd) });
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = r.bytes;
|
||||
Ok((buf[..n].to_vec(), fds))
|
||||
}
|
||||
|
||||
fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> {
|
||||
let crudos: Vec<RawFd> = fds.iter().map(|f| f.as_raw_fd()).collect();
|
||||
let cmsgs: Vec<ControlMessage> = if crudos.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![ControlMessage::ScmRights(&crudos)]
|
||||
};
|
||||
// Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir,
|
||||
// y los fds viajan sólo con el primer envío para no duplicarlos.
|
||||
let mut off = 0;
|
||||
loop {
|
||||
let resto = &datos[off..];
|
||||
let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })];
|
||||
let usar = if off == 0 { &cmsgs[..] } else { &[][..] };
|
||||
let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None)
|
||||
.map_err(io::Error::from)?;
|
||||
off += n;
|
||||
if off >= datos.len() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec<u8> {
|
||||
let tam = 8 + cuerpo.len();
|
||||
let mut v = Vec::with_capacity(tam);
|
||||
v.extend_from_slice(&obj.to_ne_bytes());
|
||||
v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes());
|
||||
v.extend_from_slice(cuerpo);
|
||||
v
|
||||
}
|
||||
|
||||
fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec<u8> {
|
||||
let mut c = Vec::new();
|
||||
c.extend_from_slice(&name.to_ne_bytes());
|
||||
escribir_str(&mut c, iface);
|
||||
c.extend_from_slice(&version.to_ne_bytes());
|
||||
msg(registry, OP_REGISTRY_GLOBAL, &c)
|
||||
}
|
||||
|
||||
/// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga.
|
||||
fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> {
|
||||
let l = UnixListener::bind(sock).unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (mut c, _) = l.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = c.read(&mut buf).unwrap();
|
||||
// Lo único que se parsea: el id de registry que pidió el cliente.
|
||||
let (obj, op, _) = cabecera(&buf[..n]).unwrap();
|
||||
assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY));
|
||||
let reg = leer_u32(&buf[8..n], 0).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for (name, iface) in &anuncios {
|
||||
out.extend_from_slice(&global(reg, *name, iface, 1));
|
||||
}
|
||||
c.write_all(&out).unwrap();
|
||||
// Se queda viva para que el proxy no vea EOF antes de tiempo.
|
||||
let _ = c.read(&mut buf);
|
||||
})
|
||||
}
|
||||
|
||||
fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
let mut off = 0;
|
||||
while let Some((obj, op, tam)) = cabecera(&datos[off..]) {
|
||||
if obj == reg && op == OP_REGISTRY_GLOBAL {
|
||||
if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) {
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
off += tam;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_cliente_no_se_entera_de_los_globals_peligrosos() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let real = d.path().join("compositor.sock");
|
||||
let h = compositor_falso(&real, vec![
|
||||
(1, "wl_compositor".into()),
|
||||
(2, "zwlr_screencopy_manager_v1".into()), // captura de pantalla
|
||||
(3, "wl_seat".into()),
|
||||
(4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas
|
||||
(5, "zwlr_data_control_manager_v1".into()), // lee el portapapeles
|
||||
(6, "xdg_wm_base".into()),
|
||||
(7, "zwlr_layer_shell_v1".into()), // dibuja encima de todo
|
||||
]);
|
||||
|
||||
let permitidos: HashSet<String> =
|
||||
GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
|
||||
let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
|
||||
|
||||
let mut c = UnixStream::connect(&p.socket).unwrap();
|
||||
let mut cuerpo = Vec::new();
|
||||
cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry
|
||||
c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
|
||||
|
||||
c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut buf = [0u8; 8192];
|
||||
let n = c.read(&mut buf).unwrap();
|
||||
let vistas = interfaces_vistas(&buf[..n], 2);
|
||||
|
||||
assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}");
|
||||
assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}");
|
||||
assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}");
|
||||
for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1",
|
||||
"zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] {
|
||||
assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}");
|
||||
}
|
||||
assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}");
|
||||
// NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
|
||||
// proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
|
||||
drop(c);
|
||||
drop(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindear_un_global_oculto_corta_la_conexion() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let real = d.path().join("c.sock");
|
||||
let h = compositor_falso(&real, vec![
|
||||
(1, "wl_compositor".into()),
|
||||
(99, "zwlr_screencopy_manager_v1".into()),
|
||||
]);
|
||||
let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
|
||||
let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
|
||||
|
||||
let mut c = UnixStream::connect(&p.socket).unwrap();
|
||||
let mut cuerpo = Vec::new();
|
||||
cuerpo.extend_from_slice(&2u32.to_ne_bytes());
|
||||
c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
|
||||
c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut buf = [0u8; 8192];
|
||||
let _ = c.read(&mut buf).unwrap();
|
||||
|
||||
// Un cliente malicioso adivina el `name` que nunca se le anunció.
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&99u32.to_ne_bytes());
|
||||
escribir_str(&mut b, "zwlr_screencopy_manager_v1");
|
||||
b.extend_from_slice(&1u32.to_ne_bytes());
|
||||
b.extend_from_slice(&3u32.to_ne_bytes());
|
||||
c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap();
|
||||
|
||||
// Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind
|
||||
// dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar.
|
||||
let n = c.read(&mut buf).unwrap_or(0);
|
||||
let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op))
|
||||
== Some((OBJ_DISPLAY, OP_DISPLAY_ERROR));
|
||||
let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 };
|
||||
assert!(hubo_error || cerro, "el bind al global oculto no fue cortado");
|
||||
// NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
|
||||
// proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
|
||||
drop(c);
|
||||
drop(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_lista_es_BLANCA_no_negra() {
|
||||
// Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una
|
||||
// denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls.
|
||||
let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
|
||||
assert!(!permitidos.contains("zwlr_screencopy_manager_v1"));
|
||||
assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9"));
|
||||
assert!(permitidos.contains("wl_compositor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_parser_no_se_cuelga_con_un_stream_corrupto() {
|
||||
// Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy.
|
||||
let mut malo = Vec::new();
|
||||
malo.extend_from_slice(&1u32.to_ne_bytes());
|
||||
malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible
|
||||
assert!(cabecera(&malo).is_none());
|
||||
assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto
|
||||
}
|
||||
}
|
||||
@@ -366,18 +366,45 @@ andar sin explicar por qué.
|
||||
bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un
|
||||
contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton.
|
||||
|
||||
### D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls
|
||||
|
||||
El compositor anuncia **todos** sus globals a quien se conecte. El proxy se mete en el medio, relaya
|
||||
todo salvo dos mensajes y con eso alcanza:
|
||||
|
||||
| mensaje | qué se hace | por qué |
|
||||
|---|---|---|
|
||||
| `wl_registry.global` (del compositor) | si el interface no está en la lista, **no se reenvía** | el cliente nunca se entera de que existe |
|
||||
| `wl_registry.bind` (del cliente) | si el `name` es uno de los ocultos, `wl_display.error` y se corta | el `name` es **un número que se puede adivinar**: ocultar sin cortar el bind dejaría el filtro en decorativo |
|
||||
|
||||
**Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón:** el
|
||||
conjunto *peligroso* de Wayland **crece** —cada compositor inventa protocolos privilegiados— mientras
|
||||
que el conjunto *necesario para dibujar una ventana* es corto y estable. Una denylist estaría
|
||||
desactualizada el día que alguien actualice el compositor, **y sin un solo error visible**. La lista
|
||||
incluye a propósito lo que los juegos piden y suele olvidarse: `pointer_constraints`,
|
||||
`relative_pointer`, `tearing_control`, `idle_inhibit`.
|
||||
|
||||
**Lo que hace esto viable en un fichero:** ningún mensaje que se descarta lleva descriptores, así que
|
||||
los fds (que Wayland pasa por `SCM_RIGHTS` para `wl_shm` y dmabuf, y sin los cuales no se dibuja
|
||||
nada) se relayan en orden de llegada **sin tener que asociarlos a su mensaje**.
|
||||
|
||||
**Probado sin compositor**, con uno FALSO que anuncia siete globals —tres permitidos y cuatro
|
||||
peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear
|
||||
por número el `name` que nunca se anunció corta la conexión. Es la clase de prueba que no necesita
|
||||
GPU y aun así responde la pregunta entera. Un quinto test cubre el `size` menor que la cabecera, que
|
||||
haría avanzar 0 bytes y colgar el proxy en un bucle.
|
||||
|
||||
---
|
||||
|
||||
## Lo que este ADR admite que NO resuelve
|
||||
|
||||
Se escriben acá para que no se descubran en producción.
|
||||
|
||||
1. **El socket de Wayland es un borde de privilegio, no un caño.** Pasarlo crudo le da a la imagen
|
||||
ajena todos los protocolos privilegiados que exponga el compositor: `screencopy`,
|
||||
`virtual-keyboard`, `input-method`. Flatpak no pasa el socket: pasa un **proxy que filtra
|
||||
globals**. No lo tenemos. Mientras no exista, **una instancia con socket de Wayland puede
|
||||
capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario.
|
||||
Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR.
|
||||
1. **El socket de Wayland — ~~el agujero~~ CERRADO 2026-09-03.** Se escribió el proxy que faltaba
|
||||
(D11): la instancia ya no ve el registro entero del compositor sino una **lista blanca**. Lo que
|
||||
queda del párrafo original es la razón por la que se hizo: pasar el socket crudo **no es abrir un
|
||||
caño, es un borde de privilegio** — `screencopy` captura la pantalla, `virtual_keyboard`
|
||||
sintetiza teclas, `data_control` lee el portapapeles sin que nadie copie y `layer_shell` dibuja
|
||||
encima de todo. Sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos.
|
||||
|
||||
2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda
|
||||
por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt`
|
||||
@@ -488,7 +515,7 @@ Se escriben acá para que no se descubran en producción.
|
||||
tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba
|
||||
borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`,
|
||||
porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar.
|
||||
8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista.
|
||||
8. ✅ **HECHO 2026-09-03.** Proxy filtrante de Wayland. Ver D11.
|
||||
|
||||
## Nombre — ADOPTADO 2026-09-03
|
||||
|
||||
|
||||
Reference in New Issue
Block a user