-
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 SFTP server subsystem using russh-sftp library. This enables file transfer operations through the SFTP protocol.
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. SFTP Server Handler
// src/server/sftp.rs
use russh_sftp::protocol::{
FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// SFTP server handler
pub struct SftpHandler {
/// Current user info
user_info: UserInfo,
/// Root directory for SFTP (chroot-like)
root_dir: PathBuf,
/// Open file handles
handles: HashMap<String, OpenHandle>,
/// Handle counter
handle_counter: u64,
/// Audit logger (optional)
audit: Option<Arc<dyn AuditExporter>>,
/// File transfer filter (optional)
filter: Option<Arc<dyn TransferFilter>>,
}
enum OpenHandle {
File {
file: tokio::fs::File,
path: PathBuf,
flags: OpenFlags,
},
Dir {
path: PathBuf,
entries: Vec<DirEntry>,
position: usize,
},
}
impl SftpHandler {
pub fn new(
user_info: UserInfo,
root_dir: Option<PathBuf>,
audit: Option<Arc<dyn AuditExporter>>,
filter: Option<Arc<dyn TransferFilter>>,
) -> Self {
let root_dir = root_dir.unwrap_or_else(|| PathBuf::from("/"));
Self {
user_info,
root_dir,
handles: HashMap::new(),
handle_counter: 0,
audit,
filter,
}
}
/// Resolve path within root (prevent path traversal)
fn resolve_path(&self, path: &str) -> Result<PathBuf, Status> {
let path = Path::new(path);
// Normalize and join with root
let resolved = if path.is_absolute() {
self.root_dir.join(path.strip_prefix("/").unwrap_or(path))
} else {
self.root_dir.join(path)
};
// Canonicalize and verify within root
let canonical = resolved.canonicalize()
.map_err(|_| Status {
code: StatusCode::NoSuchFile,
message: "Path not found".into(),
language_tag: "en".into(),
})?;
if !canonical.starts_with(&self.root_dir) {
return Err(Status {
code: StatusCode::PermissionDenied,
message: "Access denied".into(),
language_tag: "en".into(),
});
}
Ok(canonical)
}
/// Generate a new handle ID
fn new_handle(&mut self) -> String {
self.handle_counter += 1;
format!("h{}", self.handle_counter)
}
/// Check filter for file operation
fn check_filter(&self, path: &Path, operation: Operation) -> Result<(), Status> {
if let Some(ref filter) = self.filter {
match filter.check(path, operation, &self.user_info.username) {
FilterResult::Allow => Ok(()),
FilterResult::Deny => Err(Status {
code: StatusCode::PermissionDenied,
message: "Operation denied by filter".into(),
language_tag: "en".into(),
}),
FilterResult::Log => {
// Log but allow
tracing::info!("Filter log: {:?} on {:?}", operation, path);
Ok(())
}
}
} else {
Ok(())
}
}
/// Log audit event
async fn audit_log(&self, event: AuditEvent) {
if let Some(ref audit) = self.audit {
if let Err(e) = audit.export(event).await {
tracing::warn!("Failed to log audit event: {}", e);
}
}
}
}
#[async_trait]
impl russh_sftp::server::Handler for SftpHandler {
type Error = anyhow::Error;
/// Handle SFTP version negotiation
async fn init(&mut self, version: u32, extensions: HashMap<String, String>) -> Result<Version, Self::Error> {
tracing::debug!("SFTP init: version {}", version);
Ok(Version::new())
}
/// Open a file
async fn open(
&mut self,
id: u32,
filename: String,
pflags: OpenFlags,
attrs: FileAttributes,
) -> Result<Handle, Self::Error> {
let path = self.resolve_path(&filename)?;
// Check filter
let operation = if pflags.contains(OpenFlags::WRITE) {
Operation::Upload
} else {
Operation::Download
};
self.check_filter(&path, operation)?;
// Build open options
let mut opts = tokio::fs::OpenOptions::new();
if pflags.contains(OpenFlags::READ) {
opts.read(true);
}
if pflags.contains(OpenFlags::WRITE) {
opts.write(true);
}
if pflags.contains(OpenFlags::CREATE) {
opts.create(true);
}
if pflags.contains(OpenFlags::TRUNCATE) {
opts.truncate(true);
}
if pflags.contains(OpenFlags::APPEND) {
opts.append(true);
}
let file = opts.open(&path).await
.map_err(|e| self.io_to_status(e))?;
let handle_id = self.new_handle();
self.handles.insert(handle_id.clone(), OpenHandle::File {
file,
path: path.clone(),
flags: pflags,
});
// Audit log
self.audit_log(AuditEvent {
event_type: if pflags.contains(OpenFlags::WRITE) {
EventType::FileOpenWrite
} else {
EventType::FileOpenRead
},
user: self.user_info.username.clone(),
path: Some(path),
..Default::default()
}).await;
Ok(Handle { id, handle: handle_id })
}
/// Read from file
async fn read(
&mut self,
id: u32,
handle: String,
offset: u64,
len: u32,
) -> Result<russh_sftp::protocol::Data, Self::Error> {
let file = match self.handles.get_mut(&handle) {
Some(OpenHandle::File { file, .. }) => file,
_ => return Err(self.invalid_handle()),
};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buffer = vec![0u8; len as usize];
let n = file.read(&mut buffer).await?;
buffer.truncate(n);
if n == 0 {
return Err(anyhow::anyhow!("EOF"));
}
Ok(russh_sftp::protocol::Data { id, data: buffer })
}
/// Write to file
async fn write(
&mut self,
id: u32,
handle: String,
offset: u64,
data: Vec<u8>,
) -> Result<Status, Self::Error> {
let (file, path) = match self.handles.get_mut(&handle) {
Some(OpenHandle::File { file, path, .. }) => (file, path.clone()),
_ => return Ok(self.invalid_handle_status()),
};
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
file.seek(std::io::SeekFrom::Start(offset)).await?;
file.write_all(&data).await?;
// Audit log
self.audit_log(AuditEvent {
event_type: EventType::FileWrite,
user: self.user_info.username.clone(),
path: Some(path),
bytes: Some(data.len() as u64),
..Default::default()
}).await;
Ok(Status {
code: StatusCode::Ok,
message: "".into(),
language_tag: "en".into(),
})
}
/// Close file handle
async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
if let Some(h) = self.handles.remove(&handle) {
match h {
OpenHandle::File { path, flags, .. } => {
self.audit_log(AuditEvent {
event_type: EventType::FileClose,
user: self.user_info.username.clone(),
path: Some(path),
..Default::default()
}).await;
}
_ => {}
}
}
Ok(Status {
code: StatusCode::Ok,
message: "".into(),
language_tag: "en".into(),
})
}
/// Open directory
async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
let resolved = self.resolve_path(&path)?;
let mut entries = Vec::new();
let mut dir = tokio::fs::read_dir(&resolved).await
.map_err(|e| self.io_to_status(e))?;
while let Some(entry) = dir.next_entry().await? {
entries.push(entry);
}
let handle_id = self.new_handle();
self.handles.insert(handle_id.clone(), OpenHandle::Dir {
path: resolved,
entries: entries.into_iter().map(|e| e.into()).collect(),
position: 0,
});
Ok(Handle { id, handle: handle_id })
}
/// Read directory entries
async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
let (entries, position) = match self.handles.get_mut(&handle) {
Some(OpenHandle::Dir { entries, position, .. }) => (entries, position),
_ => return Err(self.invalid_handle()),
};
if *position >= entries.len() {
return Err(anyhow::anyhow!("EOF"));
}
// Return batch of entries
let batch_size = 100;
let end = (*position + batch_size).min(entries.len());
let batch: Vec<_> = entries[*position..end]
.iter()
.map(|e| e.to_sftp_name())
.collect();
*position = end;
Ok(Name { id, files: batch })
}
/// Get file attributes
async fn stat(&mut self, id: u32, path: String) -> Result<FileAttributes, Self::Error> {
let resolved = self.resolve_path(&path)?;
let metadata = tokio::fs::metadata(&resolved).await
.map_err(|e| self.io_to_status(e))?;
Ok(self.metadata_to_attrs(&metadata))
}
/// Get real path
async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
let resolved = self.resolve_path(&path)?;
let name = resolved.to_string_lossy().to_string();
Ok(Name {
id,
files: vec![russh_sftp::protocol::File {
filename: name,
longname: "".into(),
attrs: FileAttributes::default(),
}],
})
}
// ... implement other SFTP operations (mkdir, rmdir, remove, rename, etc.)
}2. Integrate with SSH Handler
// Update src/server/handler.rs
impl Handler for SshHandler {
async fn subsystem_request(
&mut self,
channel_id: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
if name != "sftp" {
session.channel_failure(channel_id)?;
return Ok(());
}
if !self.config.sftp.enabled {
tracing::debug!("SFTP disabled, rejecting request");
session.channel_failure(channel_id)?;
return Ok(());
}
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"))?;
let channel = self.channels.get(&channel_id)
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
session.channel_success(channel_id)?;
// Create SFTP handler
let sftp_handler = SftpHandler::new(
user_info,
self.config.sftp.root.clone(),
self.config.audit.clone(),
self.config.filter.clone(),
);
// Run SFTP server
let stream = channel.into_stream();
russh_sftp::server::run(stream, sftp_handler).await;
Ok(())
}
}Files to Create/Modify
| File | Action |
|---|---|
src/server/sftp.rs |
Create - SFTP handler |
src/server/handler.rs |
Modify - Integrate subsystem_request |
src/server/mod.rs |
Modify - Add sftp module |
Testing Requirements
- Unit test: Path resolution and traversal prevention
- Integration test: File upload via sftp client
- Integration test: File download via sftp client
- Integration test: Directory listing
- Integration test: File operations (rename, delete)
# Test SFTP connection
sftp -P 2222 testuser@localhost
# Test file operations
sftp> put local.txt
sftp> get remote.txt
sftp> ls -la
sftp> mkdir testdirAcceptance Criteria
- SftpHandler implements russh_sftp::server::Handler
- File open/read/write/close operations
- Directory operations (opendir, readdir, mkdir, rmdir)
- Path operations (stat, realpath, rename, remove)
- Path traversal prevention
- Optional root directory (chroot-like)
- Filter integration hooks
- Audit logging hooks
- Tests passing
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
priority:highHigh priority issueHigh priority issuestatus:doneCompletedCompletedtype:enhancementNew feature or requestNew feature or request