Skip to content

Implement PTY/shell session support for server #129

@inureyes

Description

@inureyes

Summary

Implement PTY (pseudo-terminal) and interactive shell session support for bssh-server. This enables users to get an interactive login shell through SSH.

Parent Epic

Implementation Details

1. PTY Manager

// src/server/pty.rs
use std::os::unix::io::{AsRawFd, RawFd};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

/// PTY configuration from pty_request
#[derive(Debug, Clone)]
pub struct PtyConfig {
    pub term: String,
    pub col_width: u32,
    pub row_height: u32,
    pub pix_width: u32,
    pub pix_height: u32,
    pub modes: Vec<(russh::Pty, u32)>,
}

/// Manages a pseudo-terminal session
pub struct PtySession {
    config: PtyConfig,
    master_fd: RawFd,
    slave_path: PathBuf,
    child: Option<Child>,
}

impl PtySession {
    /// Create a new PTY session
    pub fn new(config: PtyConfig) -> Result<Self> {
        // Open PTY master/slave pair
        let (master_fd, slave_path) = Self::open_pty()?;

        // Set terminal size
        Self::set_window_size(master_fd, config.col_width, config.row_height)?;

        Ok(Self {
            config,
            master_fd,
            slave_path,
            child: None,
        })
    }

    /// Open a new PTY pair
    fn open_pty() -> Result<(RawFd, PathBuf)> {
        use nix::pty::{openpty, OpenptyResult};
        use nix::unistd::close;

        let OpenptyResult { master, slave } = openpty(None, None)
            .context("Failed to open PTY")?;

        // Get slave path
        let slave_path = nix::unistd::ttyname(slave)
            .context("Failed to get slave path")?;

        // Close slave fd (will be reopened by child)
        close(slave).ok();

        Ok((master, slave_path))
    }

    /// Set terminal window size
    fn set_window_size(fd: RawFd, cols: u32, rows: u32) -> Result<()> {
        use nix::pty::Winsize;
        use nix::libc::{ioctl, TIOCSWINSZ};

        let winsize = Winsize {
            ws_row: rows as u16,
            ws_col: cols as u16,
            ws_xpixel: 0,
            ws_ypixel: 0,
        };

        unsafe {
            if ioctl(fd, TIOCSWINSZ, &winsize) < 0 {
                anyhow::bail!("Failed to set window size");
            }
        }

        Ok(())
    }

    /// Spawn shell process
    pub async fn spawn_shell(&mut self, user_info: &UserInfo) -> Result<()> {
        use std::os::unix::process::CommandExt;

        let slave_path = self.slave_path.clone();
        let shell = user_info.shell.clone();
        let home_dir = user_info.home_dir.clone();
        let username = user_info.username.clone();
        let env = user_info.env.clone();
        let term = self.config.term.clone();

        // Spawn in separate thread due to pre_exec
        let child = tokio::task::spawn_blocking(move || -> Result<Child> {
            let slave_fd = std::fs::OpenOptions::new()
                .read(true)
                .write(true)
                .open(&slave_path)?;

            let mut cmd = std::process::Command::new(&shell);
            cmd.arg("-l"); // Login shell

            // Set environment
            cmd.env_clear();
            cmd.env("HOME", &home_dir);
            cmd.env("USER", &username);
            cmd.env("SHELL", &shell);
            cmd.env("TERM", &term);
            cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");

            for (key, value) in &env {
                cmd.env(key, value);
            }

            cmd.current_dir(&home_dir);

            // Set up stdio to use PTY slave
            cmd.stdin(slave_fd.try_clone()?);
            cmd.stdout(slave_fd.try_clone()?);
            cmd.stderr(slave_fd);

            // Create new session and set controlling terminal
            unsafe {
                cmd.pre_exec(|| {
                    nix::unistd::setsid()?;
                    // TIOCSCTTY - set controlling terminal
                    nix::libc::ioctl(0, nix::libc::TIOCSCTTY, 0);
                    Ok(())
                });
            }

            let child = cmd.spawn()?;
            Ok(Child::from_std(child)?)
        }).await??;

        self.child = Some(child);
        Ok(())
    }

    /// Handle window size change
    pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
        Self::set_window_size(self.master_fd, cols, rows)
    }

    /// Get async reader for master fd
    pub fn reader(&self) -> Result<impl AsyncRead + '_> {
        use tokio::io::unix::AsyncFd;
        // Implementation using AsyncFd
        todo!()
    }

    /// Get async writer for master fd
    pub fn writer(&self) -> Result<impl AsyncWrite + '_> {
        todo!()
    }
}

impl Drop for PtySession {
    fn drop(&mut self) {
        // Kill child process if still running
        if let Some(ref mut child) = self.child {
            let _ = child.start_kill();
        }
        // Close master fd
        let _ = nix::unistd::close(self.master_fd);
    }
}

2. Shell Session Handler

// src/server/shell.rs
pub struct ShellSession {
    pty: PtySession,
    channel_id: ChannelId,
}

impl ShellSession {
    pub fn new(pty_config: PtyConfig, channel_id: ChannelId) -> Result<Self> {
        let pty = PtySession::new(pty_config)?;
        Ok(Self { pty, channel_id })
    }

    /// Start the shell session
    pub async fn start(
        &mut self,
        user_info: &UserInfo,
        handle: Handle<SshHandler>,
    ) -> Result<()> {
        self.pty.spawn_shell(user_info).await?;

        // Start I/O forwarding tasks
        let channel_id = self.channel_id;

        // Forward PTY output to SSH channel
        let pty_reader = self.pty.reader()?;
        let handle_clone = handle.clone();
        tokio::spawn(async move {
            let mut buf = [0u8; 4096];
            loop {
                match pty_reader.read(&mut buf).await {
                    Ok(0) => break,
                    Ok(n) => {
                        if handle_clone.data(channel_id, CryptoVec::from_slice(&buf[..n])).await.is_err() {
                            break;
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(())
    }

    /// Handle data from SSH channel (forward to PTY)
    pub async fn handle_data(&mut self, data: &[u8]) -> Result<()> {
        let mut writer = self.pty.writer()?;
        writer.write_all(data).await?;
        Ok(())
    }

    /// Handle window resize
    pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
        self.pty.resize(cols, rows)
    }
}

3. Integrate with SSH Handler

// Update src/server/handler.rs
impl Handler for SshHandler {
    async fn pty_request(
        &mut self,
        channel_id: ChannelId,
        term: &str,
        col_width: u32,
        row_height: u32,
        pix_width: u32,
        pix_height: u32,
        modes: &[(russh::Pty, u32)],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        tracing::debug!(
            "PTY request: term={}, size={}x{}",
            term, col_width, row_height
        );

        let pty_config = PtyConfig {
            term: term.to_string(),
            col_width,
            row_height,
            pix_width,
            pix_height,
            modes: modes.to_vec(),
        };

        // Store PTY config for when shell_request comes
        if let Some(channel_state) = self.channels.get_mut(&channel_id) {
            channel_state.pty_config = Some(pty_config);
        }

        session.channel_success(channel_id)?;
        Ok(())
    }

    async fn shell_request(
        &mut self,
        channel_id: ChannelId,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        tracing::debug!("Shell request on channel {}", channel_id);

        // Get user info
        let user = self.user.as_ref()
            .ok_or_else(|| anyhow::anyhow!("No authenticated user"))?;
        let user_info = self.config.auth_provider
            .get_user_info(user).await?
            .ok_or_else(|| anyhow::anyhow!("User not found"))?;

        // Get PTY config
        let channel_state = self.channels.get_mut(&channel_id)
            .ok_or_else(|| anyhow::anyhow!("Channel not found"))?;

        let pty_config = channel_state.pty_config.take()
            .unwrap_or_else(|| PtyConfig::default());

        // Create and start shell session
        let mut shell_session = ShellSession::new(pty_config, channel_id)?;
        shell_session.start(&user_info, session.handle()).await?;

        channel_state.shell_session = Some(shell_session);
        channel_state.mode = ChannelMode::Shell;

        session.channel_success(channel_id)?;
        Ok(())
    }

    async fn window_change_request(
        &mut self,
        channel_id: ChannelId,
        col_width: u32,
        row_height: u32,
        pix_width: u32,
        pix_height: u32,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if let Some(channel_state) = self.channels.get(&channel_id) {
            if let Some(ref shell_session) = channel_state.shell_session {
                shell_session.resize(col_width, row_height)?;
            }
        }
        Ok(())
    }

    async fn data(
        &mut self,
        channel_id: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if let Some(channel_state) = self.channels.get_mut(&channel_id) {
            match channel_state.mode {
                ChannelMode::Shell => {
                    if let Some(ref mut shell) = channel_state.shell_session {
                        shell.handle_data(data).await?;
                    }
                }
                _ => {}
            }
        }
        Ok(())
    }
}

Platform Considerations

  • This implementation uses POSIX PTY APIs (Unix-specific)
  • Windows support would require ConPTY (future enhancement)

Files to Create/Modify

File Action
src/server/pty.rs Create - PTY management
src/server/shell.rs Create - Shell session handler
src/server/handler.rs Modify - Implement PTY/shell handlers
src/server/session.rs Modify - Add shell session state
src/server/mod.rs Modify - Add pty, shell modules

Testing Requirements

  1. Unit test: PTY creation
  2. Unit test: Window size setting
  3. Integration test: Interactive shell session
  4. Integration test: Window resize
  5. Integration test: Signal handling (Ctrl+C, Ctrl+D)
# Test interactive shell
ssh -p 2222 testuser@localhost

# Should get login shell prompt
# Test window resize
# Test Ctrl+C interrupts command
# Test Ctrl+D exits shell

Acceptance Criteria

  • PTY pair creation using nix crate
  • Shell process spawning with PTY slave
  • Terminal environment setup (TERM, etc.)
  • Bidirectional I/O forwarding
  • Window size change handling
  • Proper process cleanup on disconnect
  • Signal propagation (Ctrl+C, etc.)
  • Login shell flag support
  • Tests passing

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions