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.
This commit is contained in:
@@ -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;
|
||||
|
||||
341
markbase-core/src/ssh_server/x11_forward.rs
Normal file
341
markbase-core/src/ssh_server/x11_forward.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user