Implement SSH X11 forwarding Phase 1
Some checks failed
Test / build (push) Has been cancelled
Test / test (push) Has been cancelled

- 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.
This commit is contained in:
Warren
2026-06-21 02:11:55 +08:00
parent 913296fe96
commit 929ad150d8
2 changed files with 342 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ pub mod sshbuf;
pub mod upload_hook; pub mod upload_hook;
pub mod version; pub mod version;
pub mod window_manager; pub mod window_manager;
pub mod x11_forward; // SSH X11 forwarding (RFC 4254 §7.2)
pub use packet::{PacketType, SshPacket}; pub use packet::{PacketType, SshPacket};
pub use server::SshServer; pub use server::SshServer;

View File

@@ -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<u8>,
/// 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<Self> {
// 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<X11Connection> {
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::<u16>()
.map_err(|_| anyhow!("Invalid display number: {}", display))?;
// Parse screen number (default 0)
let screen_number = if parts.len() > 1 {
parts[1].parse::<u16>()
.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<Vec<u8>> {
// 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::<u16>().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");
}
}