From 929ad150d8bfbfe3c12ce3db4d571ab5ffc8f303 Mon Sep 17 00:00:00 2001 From: Warren Date: Sun, 21 Jun 2026 02:11:55 +0800 Subject: [PATCH] Implement SSH X11 forwarding Phase 1 - x11_forward.rs module with X11ForwardContext - parse_display() to parse DISPLAY env variable - read_xauthority_cookie() to read MIT-MAGIC-COOKIE-1 - X11Connection for socket forwarding - Unit tests: parse_display/disabled/display_env All tests pass. --- markbase-core/src/ssh_server/mod.rs | 1 + markbase-core/src/ssh_server/x11_forward.rs | 341 ++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 markbase-core/src/ssh_server/x11_forward.rs diff --git a/markbase-core/src/ssh_server/mod.rs b/markbase-core/src/ssh_server/mod.rs index 8ce45db..302abae 100644 --- a/markbase-core/src/ssh_server/mod.rs +++ b/markbase-core/src/ssh_server/mod.rs @@ -22,6 +22,7 @@ pub mod sshbuf; pub mod upload_hook; pub mod version; pub mod window_manager; +pub mod x11_forward; // SSH X11 forwarding (RFC 4254 §7.2) pub use packet::{PacketType, SshPacket}; pub use server::SshServer; diff --git a/markbase-core/src/ssh_server/x11_forward.rs b/markbase-core/src/ssh_server/x11_forward.rs new file mode 100644 index 0000000..bc2f830 --- /dev/null +++ b/markbase-core/src/ssh_server/x11_forward.rs @@ -0,0 +1,341 @@ +//! SSH X11 forwarding support (RFC 4254 §7.2). +//! +//! OpenSSH supports X11 forwarding for remote graphical applications. +//! X11 connections are forwarded through SSH channel type "x11". + +use anyhow::{Result, anyhow}; +use log::info; +use std::path::PathBuf; +use std::net::TcpStream; +use std::io::{Read, Write}; + +/// X11 authentication cookie type (RFC 4254 §7.2). +#[derive(Debug, Clone)] +pub enum X11AuthType { + /// MIT-MAGIC-COOKIE-1 (most common) + MitMagicCookie1, + /// XDM-AUTHORIZATION-1 (less common) + XdmAuthorization1, +} + +/// X11 forwarding context (RFC 4254 §7.2). +#[derive(Debug, Clone)] +pub struct X11ForwardContext { + /// X11 display number (e.g., 0 for :0) + display_number: u16, + /// X11 screen number (e.g., 0 for :0.0) + screen_number: u16, + /// Authentication type + auth_type: X11AuthType, + /// Authentication cookie (16 bytes for MIT-MAGIC-COOKIE-1) + auth_cookie: Vec, + /// X11 socket path (e.g., /tmp/.X11-unix/X0) + socket_path: PathBuf, + /// Whether X11 forwarding is enabled + enabled: bool, +} + +impl X11ForwardContext { + /// Create new X11 forwarding context. + pub fn new(display: &str) -> Result { + // Parse DISPLAY environment variable (e.g., ":0", "localhost:10.0") + let (display_number, screen_number, socket_path) = parse_display(display)?; + + // Read Xauthority file to get authentication cookie + let auth_cookie = read_xauthority_cookie(display_number)?; + + info!("X11 forwarding enabled: display={}, socket={}", + display_number, socket_path.display()); + + Ok(Self { + display_number, + screen_number, + auth_type: X11AuthType::MitMagicCookie1, + auth_cookie, + socket_path, + enabled: true, + }) + } + + /// Create disabled X11 forwarding context. + pub fn disabled() -> Self { + Self { + display_number: 0, + screen_number: 0, + auth_type: X11AuthType::MitMagicCookie1, + auth_cookie: vec![0; 16], + socket_path: PathBuf::new(), + enabled: false, + } + } + + /// Check if X11 forwarding is enabled. + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Get display number. + pub fn display_number(&self) -> u16 { + self.display_number + } + + /// Get screen number. + pub fn screen_number(&self) -> u16 { + self.screen_number + } + + /// Get authentication cookie. + pub fn auth_cookie(&self) -> &[u8] { + &self.auth_cookie + } + + /// Get socket path. + pub fn socket_path(&self) -> &PathBuf { + &self.socket_path + } + + /// Connect to local X11 display. + pub fn connect(&self) -> Result { + if !self.enabled { + return Err(anyhow!("X11 forwarding disabled")); + } + + // Connect to X11 Unix socket + let socket = TcpStream::connect("127.0.0.1:6000")?; + + info!("Connected to X11 display: {}", self.display_number); + + Ok(X11Connection { + socket, + display_number: self.display_number, + }) + } + + /// Get DISPLAY environment variable value. + pub fn display_env(&self) -> String { + format!(":{}", self.display_number) + } +} + +/// X11 connection (forwarded through SSH channel). +pub struct X11Connection { + socket: TcpStream, + display_number: u16, +} + +impl X11Connection { + /// Get socket reference. + pub fn socket(&self) -> &TcpStream { + &self.socket + } + + /// Get mutable socket reference. + pub fn socket_mut(&mut self) -> &mut TcpStream { + &mut self.socket + } +} + +/// Parse DISPLAY environment variable. +/// Examples: ":0", ":0.0", "localhost:10.0" +fn parse_display(display: &str) -> Result<(u16, u16, PathBuf)> { + // Remove leading ":" if present + let display = display.trim_start_matches(':'); + + // Split display/screen number + let parts: Vec<&str> = display.split('.').collect(); + + // Parse display number + let display_number = parts[0] + .parse::() + .map_err(|_| anyhow!("Invalid display number: {}", display))?; + + // Parse screen number (default 0) + let screen_number = if parts.len() > 1 { + parts[1].parse::() + .map_err(|_| anyhow!("Invalid screen number: {}", parts[1]))? + } else { + 0 + }; + + // X11 Unix socket path (e.g., /tmp/.X11-unix/X0) + let socket_path = PathBuf::from(format!("/tmp/.X11-unix/X{}", display_number)); + + Ok((display_number, screen_number, socket_path)) +} + +/// Read Xauthority file to get authentication cookie. +/// Xauthority file: ~/.Xauthority +/// Format: family address number name data +fn read_xauthority_cookie(display_number: u16) -> Result> { + // Try to read ~/.Xauthority + let home = std::env::var("HOME") + .map_err(|_| anyhow!("HOME environment variable not set"))?; + let xauthority_path = PathBuf::from(home).join(".Xauthority"); + + if !xauthority_path.exists() { + // Generate random cookie if no .Xauthority file + info!("No .Xauthority file found, generating random cookie"); + let mut cookie = vec![0u8; 16]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut cookie); + return Ok(cookie); + } + + // Read .Xauthority file (binary format) + // Entry format: family(2) address_length(2) address number_length(2) number name_length(2) name data_length(2) data + let mut file = std::fs::File::open(&xauthority_path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + + // Parse entries to find cookie for our display + // This is simplified - proper parsing would handle all entry types + // For MIT-MAGIC-COOKIE-1, data is 16 bytes + + // Search for FamilyLocal (256) entries with display number matching + let mut cursor = 0; + while cursor < buffer.len() { + if cursor + 2 > buffer.len() { + break; + } + + let family = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]); + cursor += 2; + + // FamilyLocal (256) or FamilyWild (65535) + if family == 256 || family == 65535 { + // Skip address + if cursor + 2 > buffer.len() { + break; + } + let addr_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + addr_len; + + // Read number (display number) + if cursor + 2 > buffer.len() { + break; + } + let num_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2; + + if cursor + num_len > buffer.len() { + break; + } + let number_str = String::from_utf8_lossy(&buffer[cursor..cursor + num_len]); + cursor += num_len; + + // Check if display number matches + if number_str.parse::().ok() == Some(display_number) || family == 65535 { + // Read name (authentication protocol name) + if cursor + 2 > buffer.len() { + break; + } + let name_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2; + + if cursor + name_len > buffer.len() { + break; + } + let name = String::from_utf8_lossy(&buffer[cursor..cursor + name_len]); + cursor += name_len; + + // Check if MIT-MAGIC-COOKIE-1 + if name == "MIT-MAGIC-COOKIE-1" { + // Read data (cookie) + if cursor + 2 > buffer.len() { + break; + } + let data_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2; + + if cursor + data_len > buffer.len() { + break; + } + let cookie = buffer[cursor..cursor + data_len].to_vec(); + info!("Found MIT-MAGIC-COOKIE-1: {} bytes", cookie.len()); + return Ok(cookie); + } else { + // Skip data + if cursor + 2 > buffer.len() { + break; + } + let data_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + data_len; + } + } else { + // Skip name and data + if cursor + 2 > buffer.len() { + break; + } + let name_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + name_len; + + if cursor + 2 > buffer.len() { + break; + } + let data_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + data_len; + } + } else { + // Skip other family types + if cursor + 2 > buffer.len() { + break; + } + let addr_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + addr_len; + + if cursor + 2 > buffer.len() { + break; + } + let num_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + num_len; + + if cursor + 2 > buffer.len() { + break; + } + let name_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + name_len; + + if cursor + 2 > buffer.len() { + break; + } + let data_len = u16::from_be_bytes([buffer[cursor], buffer[cursor + 1]]) as usize; + cursor += 2 + data_len; + } + } + + // No cookie found, generate random + info!("No matching Xauthority entry found, generating random cookie"); + let mut cookie = vec![0u8; 16]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut cookie); + Ok(cookie) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_display() { + let (display, screen, path) = parse_display(":0").unwrap(); + assert_eq!(display, 0); + assert_eq!(screen, 0); + assert_eq!(path.to_str().unwrap(), "/tmp/.X11-unix/X0"); + + let (display, screen, _) = parse_display(":10.0").unwrap(); + assert_eq!(display, 10); + assert_eq!(screen, 0); + } + + #[test] + fn test_x11_disabled() { + let ctx = X11ForwardContext::disabled(); + assert!(!ctx.is_enabled()); + } + + #[test] + fn test_display_env() { + let ctx = X11ForwardContext::new(":0").unwrap(); + assert_eq!(ctx.display_env(), ":0"); + } +} \ No newline at end of file