Skip to content

Commit 816d767

Browse files
authored
feat: Implement PTY/shell session support for server (#129) (#153)
* feat: Implement PTY/shell session support for server (#129) Add Unix PTY handling for interactive shell sessions in bssh-server: - Create pty.rs module for PTY master/slave pair management with async I/O - Create shell.rs module for shell process spawning and I/O forwarding - Implement shell_request handler to start interactive shell sessions - Implement window_change_request handler for terminal resizing - Update data handler to forward SSH data to active shell sessions - Add shell session state tracking to ChannelState - Enable nix crate 'term' and 'fs' features for PTY operations * fix(security): Fix PTY fd handling and add shell validation Priority: CRITICAL/MEDIUM Issues addressed: - Fixed file descriptor leak via mem::forget by using dup() for each stdio fd - Added shell path validation before spawning process - Added bounds checking for window size dimensions (clamp to u16::MAX) - Improved error context for TIOCSWINSZ ioctl failures - Fixed clippy warning for const assertions in tests Review-Iteration: 1 * test(server): Add comprehensive tests for PTY/shell modules - Add unit tests for PTY configuration, winsize, and boundary values - Add unit tests for PTY master operations (resize, config, path) - Add tests for shell session structures and validation - Update ARCHITECTURE.md with PTY/shell module documentation - Update server-configuration.md with shell session architecture
1 parent 879b3fa commit 816d767

8 files changed

Lines changed: 1482 additions & 13 deletions

File tree

ARCHITECTURE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,21 @@ SSH server implementation using the russh library for accepting incoming connect
289289
- PTY, exec, shell, and subsystem request handling
290290
- Command execution with stdout/stderr streaming
291291

292+
- **PTY Module** (`src/server/pty.rs`): Pseudo-terminal management for interactive sessions
293+
- PTY master/slave pair creation using POSIX APIs via nix crate
294+
- Window size management with TIOCSWINSZ ioctl
295+
- Async I/O for PTY master file descriptor using tokio's AsyncFd
296+
- Configuration management (terminal type, dimensions, pixel sizes)
297+
- Implements `AsyncRead` and `AsyncWrite` for PTY I/O
298+
299+
- **Shell Session Module** (`src/server/shell.rs`): Interactive shell session handler
300+
- Shell process spawning with login shell configuration (-l flag)
301+
- Terminal environment setup (TERM, HOME, USER, SHELL, PATH)
302+
- Bidirectional I/O forwarding between SSH channel and PTY master
303+
- Window resize event handling forwarded to PTY
304+
- Proper session cleanup on disconnect (SIGHUP to shell, process termination)
305+
- Controlling terminal setup via TIOCSCTTY ioctl
306+
292307
- **CommandExecutor**: Executes commands requested by SSH clients
293308
- Shell-based command execution with `-c` flag
294309
- Environment variable configuration (HOME, USER, SHELL, PATH)

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ regex = "1.12.2"
4343
lazy_static = "1.5"
4444
ctrlc = "3.5.1"
4545
signal-hook = "0.4.1"
46-
nix = { version = "0.30", features = ["poll", "process", "signal"] }
46+
nix = { version = "0.30", features = ["fs", "poll", "process", "signal", "term"] }
4747
atty = "0.2.14"
4848
arrayvec = "0.7.6"
4949
smallvec = "1.15.1"

docs/architecture/server-configuration.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,89 @@ bssh-server -c /etc/bssh/server.yaml -p 2222 -b 0.0.0.0
403403
bssh-server -c /etc/bssh/server.yaml -D -vvv
404404
```
405405

406+
## Shell Session Architecture
407+
408+
The bssh-server supports interactive shell sessions through a PTY (pseudo-terminal) subsystem. This enables users to connect and run interactive programs like vim, top, or bash.
409+
410+
### PTY Management
411+
412+
The PTY module (`src/server/pty.rs`) handles pseudo-terminal operations:
413+
414+
**Key Components:**
415+
- **PtyMaster**: Manages the master side of a PTY pair
416+
- Opens PTY pair using `openpty()` from the nix crate
417+
- Provides async I/O via tokio's `AsyncFd`
418+
- Handles window resize events with `TIOCSWINSZ` ioctl
419+
- Configurable terminal type and dimensions
420+
421+
**Configuration:**
422+
```rust
423+
use bssh::server::pty::{PtyConfig, PtyMaster};
424+
425+
// Create PTY with custom configuration
426+
let config = PtyConfig::new(
427+
"xterm-256color".to_string(), // Terminal type
428+
80, // Columns
429+
24, // Rows
430+
0, // Pixel width (optional)
431+
0, // Pixel height (optional)
432+
);
433+
434+
let pty = PtyMaster::open(config)?;
435+
```
436+
437+
### Shell Session Handler
438+
439+
The shell module (`src/server/shell.rs`) manages interactive SSH shell sessions:
440+
441+
**Features:**
442+
- Spawns user's login shell with `-l` flag
443+
- Sets up proper terminal environment (TERM, HOME, USER, SHELL, PATH)
444+
- Creates new session and sets controlling terminal (setsid, TIOCSCTTY)
445+
- Bidirectional I/O forwarding between SSH channel and PTY
446+
- Window resize event forwarding
447+
- Graceful shutdown with process cleanup
448+
449+
**Session Lifecycle:**
450+
1. SSH client sends `pty-request` with terminal configuration
451+
2. SSH client sends `shell` request
452+
3. Server creates PTY pair and spawns shell process
453+
4. I/O forwarding tasks handle data flow:
454+
- PTY master -> SSH channel (stdout/stderr)
455+
- SSH channel -> PTY master (stdin)
456+
5. Window resize events update PTY dimensions
457+
6. On disconnect, shell process receives SIGHUP
458+
459+
**Platform Support:**
460+
- Unix/Linux: Full support using POSIX PTY APIs
461+
- Windows: Not yet supported (would require ConPTY)
462+
463+
### SSH Handler Integration
464+
465+
The `SshHandler` orchestrates shell sessions through several handler methods:
466+
467+
```
468+
SSH Client Request Flow:
469+
┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
470+
│ pty_request │ --> │ Store PtyConfig │ --> │ channel_success │
471+
└───────────────┘ └─────────────────┘ └──────────────────┘
472+
473+
v
474+
┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
475+
│ shell_request │ --> │ Create Session │ --> │ Start I/O Tasks │
476+
└───────────────┘ └─────────────────┘ └──────────────────┘
477+
478+
v
479+
┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
480+
│ data │ --> │ Forward to PTY │ --> │ User Typing │
481+
└───────────────┘ └─────────────────┘ └──────────────────┘
482+
483+
v
484+
┌───────────────────┐ ┌─────────────────┐ ┌──────────────┐
485+
│ window_change_req │ --> │ Resize PTY │ --> │ TIOCSWINSZ │
486+
└───────────────────┘ └─────────────────┘ └──────────────┘
487+
```
488+
406489
---
407490

408491
**Related Documentation:**

src/server/handler.rs

Lines changed: 195 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ use zeroize::Zeroizing;
3131
use super::auth::AuthProvider;
3232
use super::config::ServerConfig;
3333
use super::exec::CommandExecutor;
34+
use super::pty::PtyConfig as PtyMasterConfig;
3435
use super::session::{ChannelState, PtyConfig, SessionId, SessionInfo, SessionManager};
3536
use super::sftp::SftpHandler;
37+
use super::shell::ShellSession;
3638
use crate::shared::rate_limit::RateLimiter;
3739

3840
/// SSH handler for a single client connection.
@@ -716,22 +718,124 @@ impl russh::server::Handler for SshHandler {
716718

717719
/// Handle shell request.
718720
///
719-
/// Placeholder implementation - will be implemented in a future issue.
721+
/// Starts an interactive shell session for the authenticated user.
720722
fn shell_request(
721723
&mut self,
722724
channel_id: ChannelId,
723725
session: &mut Session,
724726
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
725-
tracing::debug!("Shell request");
727+
tracing::debug!(channel = ?channel_id, "Shell request");
726728

727-
if let Some(channel_state) = self.channels.get_mut(&channel_id) {
728-
channel_state.set_shell();
729-
}
729+
// Get authenticated user info
730+
let username = match self.session_info.as_ref().and_then(|s| s.user.clone()) {
731+
Some(user) => user,
732+
None => {
733+
tracing::warn!(
734+
channel = ?channel_id,
735+
"Shell request without authenticated user"
736+
);
737+
let _ = session.channel_failure(channel_id);
738+
return async { Ok(()) }.boxed();
739+
}
740+
};
730741

731-
// Placeholder - reject for now
732-
// Will be implemented in #129
733-
let _ = session.channel_failure(channel_id);
734-
async { Ok(()) }
742+
// Get PTY configuration (if set during pty_request)
743+
let pty_config = self
744+
.channels
745+
.get(&channel_id)
746+
.and_then(|state| state.pty.as_ref())
747+
.map(|pty| {
748+
PtyMasterConfig::new(
749+
pty.term.clone(),
750+
pty.col_width,
751+
pty.row_height,
752+
pty.pix_width,
753+
pty.pix_height,
754+
)
755+
})
756+
.unwrap_or_default();
757+
758+
// Clone what we need for the async block
759+
let auth_provider = Arc::clone(&self.auth_provider);
760+
let handle = session.handle();
761+
let peer_addr = self.peer_addr;
762+
763+
// Get mutable reference to channel state
764+
let channels = &mut self.channels;
765+
766+
// Signal success before starting shell
767+
let _ = session.channel_success(channel_id);
768+
769+
async move {
770+
// Get user info from auth provider
771+
let user_info = match auth_provider.get_user_info(&username).await {
772+
Ok(Some(info)) => info,
773+
Ok(None) => {
774+
tracing::error!(
775+
user = %username,
776+
"User not found after authentication for shell"
777+
);
778+
let _ = handle.close(channel_id).await;
779+
return Ok(());
780+
}
781+
Err(e) => {
782+
tracing::error!(
783+
user = %username,
784+
error = %e,
785+
"Failed to get user info for shell"
786+
);
787+
let _ = handle.close(channel_id).await;
788+
return Ok(());
789+
}
790+
};
791+
792+
tracing::info!(
793+
user = %username,
794+
peer = ?peer_addr,
795+
term = %pty_config.term,
796+
size = %format!("{}x{}", pty_config.col_width, pty_config.row_height),
797+
"Starting shell session"
798+
);
799+
800+
// Create shell session
801+
let mut shell_session = match ShellSession::new(channel_id, pty_config) {
802+
Ok(session) => session,
803+
Err(e) => {
804+
tracing::error!(
805+
user = %username,
806+
error = %e,
807+
"Failed to create shell session"
808+
);
809+
let _ = handle.close(channel_id).await;
810+
return Ok(());
811+
}
812+
};
813+
814+
// Start shell session
815+
if let Err(e) = shell_session.start(&user_info, handle.clone()).await {
816+
tracing::error!(
817+
user = %username,
818+
error = %e,
819+
"Failed to start shell session"
820+
);
821+
let _ = handle.close(channel_id).await;
822+
return Ok(());
823+
}
824+
825+
// Store shell session in channel state
826+
if let Some(channel_state) = channels.get_mut(&channel_id) {
827+
channel_state.set_shell_session(shell_session);
828+
}
829+
830+
tracing::info!(
831+
user = %username,
832+
peer = ?peer_addr,
833+
"Shell session started"
834+
);
835+
836+
Ok(())
837+
}
838+
.boxed()
735839
}
736840

737841
/// Handle subsystem request.
@@ -849,19 +953,98 @@ impl russh::server::Handler for SshHandler {
849953
}
850954

851955
/// Handle incoming data from the client.
956+
///
957+
/// Forwards data to the active shell session if one exists.
852958
fn data(
853959
&mut self,
854-
_channel_id: ChannelId,
960+
channel_id: ChannelId,
855961
data: &[u8],
856962
_session: &mut Session,
857963
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
858964
tracing::trace!(
965+
channel = ?channel_id,
859966
bytes = %data.len(),
860967
"Received data"
861968
);
862969

863-
// Placeholder - data handling will be implemented with exec/shell/sftp
864-
async { Ok(()) }
970+
// Get the data sender if there's an active shell session
971+
let data_sender = self
972+
.channels
973+
.get(&channel_id)
974+
.and_then(|state| state.shell_session.as_ref())
975+
.and_then(|shell| shell.data_sender());
976+
977+
if let Some(tx) = data_sender {
978+
let data = data.to_vec();
979+
return async move {
980+
if let Err(e) = tx.send(data).await {
981+
tracing::debug!(
982+
channel = ?channel_id,
983+
error = %e,
984+
"Error forwarding data to shell"
985+
);
986+
}
987+
Ok(())
988+
}
989+
.boxed();
990+
}
991+
992+
async { Ok(()) }.boxed()
993+
}
994+
995+
/// Handle window size change request.
996+
///
997+
/// Updates the PTY window size for active shell sessions.
998+
#[allow(clippy::too_many_arguments)]
999+
fn window_change_request(
1000+
&mut self,
1001+
channel_id: ChannelId,
1002+
col_width: u32,
1003+
row_height: u32,
1004+
pix_width: u32,
1005+
pix_height: u32,
1006+
_session: &mut Session,
1007+
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
1008+
tracing::debug!(
1009+
channel = ?channel_id,
1010+
cols = col_width,
1011+
rows = row_height,
1012+
"Window change request"
1013+
);
1014+
1015+
// Update stored PTY config
1016+
if let Some(state) = self.channels.get_mut(&channel_id) {
1017+
if let Some(ref mut pty) = state.pty {
1018+
pty.col_width = col_width;
1019+
pty.row_height = row_height;
1020+
pty.pix_width = pix_width;
1021+
pty.pix_height = pix_height;
1022+
}
1023+
}
1024+
1025+
// Get the PTY mutex if there's an active shell session
1026+
let pty_mutex = self
1027+
.channels
1028+
.get(&channel_id)
1029+
.and_then(|state| state.shell_session.as_ref())
1030+
.map(|shell| Arc::clone(shell.pty()));
1031+
1032+
if let Some(pty) = pty_mutex {
1033+
return async move {
1034+
let mut pty_guard = pty.lock().await;
1035+
if let Err(e) = pty_guard.resize(col_width, row_height) {
1036+
tracing::debug!(
1037+
channel = ?channel_id,
1038+
error = %e,
1039+
"Error resizing shell PTY"
1040+
);
1041+
}
1042+
Ok(())
1043+
}
1044+
.boxed();
1045+
}
1046+
1047+
async { Ok(()) }.boxed()
8651048
}
8661049

8671050
/// Handle channel EOF from the client.

src/server/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ pub mod auth;
4848
pub mod config;
4949
pub mod exec;
5050
pub mod handler;
51+
pub mod pty;
5152
pub mod session;
5253
pub mod sftp;
54+
pub mod shell;
5355

5456
use std::net::SocketAddr;
5557
use std::path::Path;
@@ -66,9 +68,11 @@ use crate::shared::rate_limit::RateLimiter;
6668
pub use self::config::{ServerConfig, ServerConfigBuilder};
6769
pub use self::exec::{CommandExecutor, ExecConfig};
6870
pub use self::handler::SshHandler;
71+
pub use self::pty::{PtyConfig as PtyMasterConfig, PtyMaster};
6972
pub use self::session::{
7073
ChannelMode, ChannelState, PtyConfig, SessionId, SessionInfo, SessionManager,
7174
};
75+
pub use self::shell::ShellSession;
7276

7377
/// The main SSH server struct.
7478
///

0 commit comments

Comments
 (0)