-
Notifications
You must be signed in to change notification settings - Fork 1
Closed
Labels
priority:highHigh priority issueHigh priority issuestatus:doneCompletedCompletedtype:enhancementNew feature or requestNew feature or request
Description
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
- Implement bssh-server with SFTP/SCP support #123 - bssh-server 추가 구현
- Depends on: Implement basic SSH server handler with russh #125 (basic SSH server handler)
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/zeroFiles 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
- Unit test: Command validation (allowed/blocked)
- Unit test: Environment variable setting
- Integration test: Execute simple command
- Integration test: Capture stdout/stderr separately
- Integration test: Exit code propagation
- 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
-
CommandExecutorimplementation - 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
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
priority:highHigh priority issueHigh priority issuestatus:doneCompletedCompletedtype:enhancementNew feature or requestNew feature or request