Skip to content

Implement command execution handler for server #128

@inureyes

Description

@inureyes

Summary

Implement the command execution handler (exec_request) for bssh-server. This enables clients to execute remote commands, which is core SSH functionality.

Parent Epic

Implementation Details

1. Command Executor

// src/server/exec.rs
use tokio::process::{Command, Child};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

/// Configuration for command execution
pub struct ExecConfig {
    /// Default shell for command execution
    pub default_shell: PathBuf,
    /// Environment variables to set
    pub env: HashMap<String, String>,
    /// Command timeout (None = no timeout)
    pub timeout: Option<Duration>,
    /// Working directory
    pub working_dir: Option<PathBuf>,
    /// Allowed commands (None = allow all)
    pub allowed_commands: Option<Vec<String>>,
    /// Blocked commands
    pub blocked_commands: Vec<String>,
}

impl Default for ExecConfig {
    fn default() -> Self {
        Self {
            default_shell: PathBuf::from("/bin/sh"),
            env: HashMap::new(),
            timeout: Some(Duration::from_secs(3600)), // 1 hour default
            working_dir: None,
            allowed_commands: None,
            blocked_commands: vec![],
        }
    }
}

/// Command executor for SSH exec requests
pub struct CommandExecutor {
    config: ExecConfig,
}

impl CommandExecutor {
    pub fn new(config: ExecConfig) -> Self {
        Self { config }
    }

    /// Execute a command and stream output
    pub async fn execute(
        &self,
        command: &str,
        user_info: &UserInfo,
        channel: &mut Channel<Msg>,
        session: &mut Session,
    ) -> Result<i32> {
        // Validate command
        self.validate_command(command)?;

        tracing::info!("Executing command for user {}: {}", user_info.username, command);

        // Build the command
        let mut cmd = Command::new(&self.config.default_shell);
        cmd.arg("-c").arg(command);

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

        // Add configured environment
        for (key, value) in &self.config.env {
            cmd.env(key, value);
        }

        // Add user-specific environment
        for (key, value) in &user_info.env {
            cmd.env(key, value);
        }

        // Set working directory
        let work_dir = self.config.working_dir
            .clone()
            .unwrap_or_else(|| user_info.home_dir.clone());
        cmd.current_dir(&work_dir);

        // Configure stdio
        cmd.stdin(std::process::Stdio::null());
        cmd.stdout(std::process::Stdio::piped());
        cmd.stderr(std::process::Stdio::piped());

        // Spawn process
        let mut child = cmd.spawn()
            .context("Failed to spawn command")?;

        let channel_id = channel.id();

        // Stream stdout
        let stdout = child.stdout.take();
        let stderr = child.stderr.take();

        // Create streaming tasks
        let stdout_task = Self::stream_output(stdout, channel_id, session.handle(), false);
        let stderr_task = Self::stream_output(stderr, channel_id, session.handle(), true);

        // Wait for completion with optional timeout
        let exit_status = if let Some(timeout) = self.config.timeout {
            match tokio::time::timeout(timeout, child.wait()).await {
                Ok(status) => status?,
                Err(_) => {
                    tracing::warn!("Command timed out, killing process");
                    child.kill().await?;
                    return Ok(124); // Standard timeout exit code
                }
            }
        } else {
            child.wait().await?
        };

        // Wait for output streams to complete
        let _ = tokio::join!(stdout_task, stderr_task);

        let exit_code = exit_status.code().unwrap_or(1);
        tracing::debug!("Command completed with exit code: {}", exit_code);

        Ok(exit_code)
    }

    /// Stream process output to SSH channel
    async fn stream_output(
        output: Option<impl AsyncReadExt + Unpin>,
        channel_id: ChannelId,
        handle: Handle<SshHandler>,
        is_stderr: bool,
    ) -> Result<()> {
        let Some(mut output) = output else {
            return Ok(());
        };

        let mut buffer = [0u8; 8192];
        loop {
            let n = output.read(&mut buffer).await?;
            if n == 0 {
                break;
            }

            let data = &buffer[..n];
            if is_stderr {
                handle.extended_data(channel_id, 1, CryptoVec::from_slice(data)).await?;
            } else {
                handle.data(channel_id, CryptoVec::from_slice(data)).await?;
            }
        }

        Ok(())
    }

    /// Validate command against allowed/blocked lists
    fn validate_command(&self, command: &str) -> Result<()> {
        // Check blocked commands
        for blocked in &self.config.blocked_commands {
            if command.contains(blocked) {
                anyhow::bail!("Command '{}' is blocked", blocked);
            }
        }

        // Check allowed commands (if whitelist is configured)
        if let Some(ref allowed) = self.config.allowed_commands {
            let cmd_name = command.split_whitespace().next().unwrap_or("");
            if !allowed.iter().any(|a| cmd_name == a || command.starts_with(a)) {
                anyhow::bail!("Command '{}' is not in allowed list", cmd_name);
            }
        }

        Ok(())
    }
}

2. Integrate with SSH Handler

// Update src/server/handler.rs
impl Handler for SshHandler {
    async fn exec_request(
        &mut self,
        channel_id: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        // Parse command
        let command = std::str::from_utf8(data)
            .context("Invalid UTF-8 in command")?;

        tracing::debug!("Exec request on channel {}: {}", channel_id, command);

        // 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"))?;

        // Confirm channel
        session.channel_success(channel_id)?;

        // Get channel for this request
        let channel = self.channels.get_mut(&channel_id)
            .ok_or_else(|| anyhow::anyhow!("Channel not found"))?;

        // Execute command
        let executor = CommandExecutor::new(self.config.exec.clone());
        let exit_code = executor.execute(command, &user_info, channel, session).await
            .unwrap_or_else(|e| {
                tracing::error!("Command execution failed: {}", e);
                1
            });

        // Send exit status
        session.exit_status_request(channel_id, exit_code as u32)?;
        session.channel_eof(channel_id)?;
        session.channel_close(channel_id)?;

        Ok(())
    }
}

3. Configuration

# Server config
exec:
  default_shell: /bin/sh
  timeout: 3600  # seconds, 0 = no timeout
  working_dir: /tmp  # default working directory
  env:
    LANG: en_US.UTF-8
    PATH: /usr/local/bin:/usr/bin:/bin
  # Optional command restrictions
  # allowed_commands:
  #   - ls
  #   - cat
  #   - echo
  blocked_commands:
    - rm -rf /
    - mkfs
    - dd if=/dev/zero

Files to Create/Modify

File Action
src/server/exec.rs Create - Command executor
src/server/handler.rs Modify - Implement exec_request
src/server/config/types.rs Modify - Add exec config
src/server/mod.rs Modify - Add exec module

Testing Requirements

  1. Unit test: Command validation (allowed/blocked)
  2. Unit test: Environment variable setting
  3. Integration test: Execute simple command
  4. Integration test: Capture stdout/stderr separately
  5. Integration test: Exit code propagation
  6. Integration test: Command timeout
# Test simple command
ssh -p 2222 testuser@localhost "echo hello"

# Test stderr
ssh -p 2222 testuser@localhost "echo error >&2"

# Test exit code
ssh -p 2222 testuser@localhost "exit 42"; echo $?

Acceptance Criteria

  • CommandExecutor implementation
  • stdout/stderr streaming to SSH channel
  • Exit code propagation
  • Environment variable configuration
  • Working directory configuration
  • Command timeout support
  • Command allow/block list support
  • Proper channel lifecycle (success, eof, close)
  • Logging for executed commands
  • 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