pub mod actions;
pub mod sftp_handler;
use crate::llm::action_helper::call_llm;
use crate::llm::actions::protocol_trait::ActionResult;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use actions::{SshProtocol, SSH_AUTH_EVENT, SSH_BANNER_EVENT, SSH_SHELL_COMMAND_EVENT};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use russh::server::{Auth, Msg, Session};
use russh::{Channel, ChannelId, CryptoVec};
use russh_keys::key::KeyPair;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn};
pub const MAX_SHELL_LINE_BYTES: usize = 64 * 1024;
const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const IDLE_SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"Exceeded MaxStartups\r\n";
struct DeadlinedStream {
reader: crate::server::accept_bounded::IdleTimeoutReader<
tokio::io::ReadHalf<tokio::net::TcpStream>,
>,
writer: tokio::io::WriteHalf<tokio::net::TcpStream>,
owner: CancellationToken,
owner_gone: std::pin::Pin<Box<tokio_util::sync::WaitForCancellationFutureOwned>>,
}
impl DeadlinedStream {
fn new(
stream: tokio::net::TcpStream,
first: std::time::Duration,
idle: std::time::Duration,
owner: CancellationToken,
) -> Self {
let (read_half, writer) = tokio::io::split(stream);
Self {
reader: crate::server::accept_bounded::IdleTimeoutReader::with_first(
read_half, first, idle,
),
writer,
owner_gone: Box::pin(owner.clone().cancelled_owned()),
owner,
}
}
fn owner_dropped(&mut self, cx: &mut std::task::Context<'_>) -> Option<std::io::Error> {
let gone = self.owner.is_cancelled()
|| std::future::Future::poll(self.owner_gone.as_mut(), cx).is_ready();
gone.then(|| {
std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"SSH connection owner dropped",
)
})
}
}
impl tokio::io::AsyncRead for DeadlinedStream {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if let Some(e) = self.owner_dropped(cx) {
return std::task::Poll::Ready(Err(e));
}
std::pin::Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
impl tokio::io::AsyncWrite for DeadlinedStream {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
if let Some(e) = self.owner_dropped(cx) {
return std::task::Poll::Ready(Err(e));
}
std::pin::Pin::new(&mut self.writer).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if let Some(e) = self.owner_dropped(cx) {
return std::task::Poll::Ready(Err(e));
}
std::pin::Pin::new(&mut self.writer).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if let Some(e) = self.owner_dropped(cx) {
return std::task::Poll::Ready(Err(e));
}
std::pin::Pin::new(&mut self.writer).poll_shutdown(cx)
}
}
pub(crate) async fn until_owner_drops<T>(
owner: &CancellationToken,
call: impl std::future::Future<Output = Result<T>>,
) -> Result<T> {
tokio::select! {
biased;
_ = owner.cancelled() => Err(anyhow::anyhow!(
"SSH connection closed by its owner before the answer arrived"
)),
result = call => result,
}
}
#[derive(Clone, Debug)]
pub struct SshServerConfig {
pub shell_enabled: bool,
pub sftp_enabled: bool,
}
impl Default for SshServerConfig {
fn default() -> Self {
Self {
shell_enabled: true,
sftp_enabled: true,
}
}
}
pub struct SshServer {
_config: SshServerConfig,
_llm_client: OllamaClient,
_app_state: Arc<AppState>,
_status_tx: mpsc::UnboundedSender<String>,
_server_id: Option<crate::state::ServerId>,
}
impl SshServer {
pub fn new(
config: SshServerConfig,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: Option<crate::state::ServerId>,
) -> Self {
Self {
_config: config,
_llm_client: llm_client,
_app_state: app_state,
_status_tx: status_tx,
_server_id: server_id,
}
}
pub async fn spawn_with_llm(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
) -> Result<SocketAddr> {
let config = SshServerConfig::default();
Self::spawn_with_config(listen_addr, config, llm_client, app_state, status_tx, None).await
}
pub async fn spawn_with_config(
listen_addr: SocketAddr,
config: SshServerConfig,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: Option<crate::state::ServerId>,
) -> Result<SocketAddr> {
let key_pair = generate_host_key()?;
let russh_config = russh::server::Config {
inactivity_timeout: Some(IDLE_SESSION_TIMEOUT),
auth_rejection_time: std::time::Duration::from_secs(3),
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
keys: vec![key_pair],
..Default::default()
};
let russh_config = Arc::new(russh_config);
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let actual_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!(
"SSH server listening on {} (shell: {}, sftp: {})",
actual_addr, config.shell_enabled, config.sftp_enabled
));
let task_registrar = app_state.clone();
let accept_handle = tokio::spawn(async move {
Log::new(Some(&status_tx))
.debug(format!("SSH: Accept loop started on {}", actual_addr));
let mut connection_counter = 0u64;
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"SSH",
Some(&status_tx),
)
.await
{
Ok((tcp_stream, peer_addr, permit)) => {
connection_counter += 1;
Log::new(Some(&status_tx)).info(format!(
"SSH: Accepted TCP connection #{} from {}",
connection_counter, peer_addr
));
debug!("SSH: Creating handler for connection from {}", peer_addr);
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
if let Some(server_id_val) = server_id {
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr: actual_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id_val, async move {
app_state_clone
.add_connection_to_server(server_id_val, conn_state)
.await;
let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
})
.await;
}
let owner = CancellationToken::new();
let handler = SshHandler::new(
connection_id,
config.clone(),
llm_client.clone(),
app_state.clone(),
status_tx.clone(),
server_id,
Some(peer_addr),
owner.clone(),
);
let config_clone = russh_config.clone();
let status_tx_clone = status_tx.clone();
let app_state_close = app_state.clone();
let task_owner = app_state.clone();
let ssh_connection = async move {
{
let _permit = permit;
let _cancel_on_drop = owner.clone().drop_guard();
Log::new(Some(&status_tx_clone))
.debug(format!("SSH: Starting SSH protocol for {}", peer_addr));
let tcp_stream = DeadlinedStream::new(
tcp_stream,
FIRST_BYTE_READ_TIMEOUT,
IDLE_SESSION_TIMEOUT,
owner,
);
let session =
russh::server::run_stream(config_clone, tcp_stream, handler)
.await;
let outcome = match session {
Ok(running) => running.await,
Err(e) => Err(e),
};
match outcome {
Ok(_) => {
Log::new(Some(&status_tx_clone))
.info(format!("SSH: Connection closed: {}", peer_addr));
}
Err(e) => {
Log::new(Some(&status_tx_clone)).error(format!(
"SSH: Connection error from {}: {}",
peer_addr, e
));
}
}
if let Some(server_id_val) = server_id {
app_state_close
.close_connection_on_server(server_id_val, connection_id)
.await;
let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
}
}
};
match server_id {
Some(sid) => {
task_owner.spawn_server_task(sid, ssh_connection).await;
}
None => {
tokio::spawn(ssh_connection);
}
}
}
Err(e) => {
Log::new(Some(&status_tx)).error(format!("SSH: Accept error: {}", e));
break;
}
}
}
debug!("SSH: Accept loop ended");
});
if let Some(server_id_val) = server_id {
task_registrar
.register_server_task(server_id_val, accept_handle)
.await;
}
Ok(actual_addr)
}
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<SocketAddr> {
let config = SshServerConfig::default();
Self::spawn_with_config(
listen_addr,
config,
llm_client,
app_state,
status_tx,
Some(server_id),
)
.await
}
}
fn generate_host_key() -> Result<KeyPair> {
let key =
KeyPair::generate_ed25519().ok_or_else(|| anyhow!("Failed to generate Ed25519 key"))?;
Ok(key)
}
pub struct SshHandler {
connection_id: ConnectionId,
config: SshServerConfig,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
#[allow(dead_code)] server_id: Option<crate::state::ServerId>,
#[allow(dead_code)] remote_addr: Option<SocketAddr>,
protocol: Arc<SshProtocol>,
channel_types: Arc<Mutex<HashMap<ChannelId, ChannelType>>>,
channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
shell_buffers: Arc<Mutex<HashMap<ChannelId, Vec<u8>>>>,
channel_initialized: Arc<Mutex<HashMap<ChannelId, bool>>>,
channels_closed: Arc<Mutex<HashSet<ChannelId>>>,
owner: CancellationToken,
}
#[derive(Debug, Clone)]
enum ChannelType {
Session,
Sftp,
}
impl SshHandler {
fn new(
connection_id: ConnectionId,
config: SshServerConfig,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: Option<crate::state::ServerId>,
remote_addr: Option<SocketAddr>,
owner: CancellationToken,
) -> Self {
Self {
connection_id,
config,
llm_client,
app_state,
status_tx,
server_id,
remote_addr,
protocol: Arc::new(SshProtocol::new()),
channel_types: Arc::new(Mutex::new(HashMap::new())),
channels: Arc::new(Mutex::new(HashMap::new())),
shell_buffers: Arc::new(Mutex::new(HashMap::new())),
channel_initialized: Arc::new(Mutex::new(HashMap::new())),
channels_closed: Arc::new(Mutex::new(HashSet::new())),
owner,
}
}
async fn close_channel_once(&self, session: &mut Session, channel_id: ChannelId) {
if self.channels_closed.lock().await.insert(channel_id) {
session.close(channel_id);
} else {
debug!(
"SSH channel {} already closed by this side; not sending a second SSH_MSG_CHANNEL_CLOSE",
channel_id
);
}
}
fn normalize_line_endings(text: &str) -> String {
text.replace("\r\n", "\n").replace('\n', "\r\n")
}
async fn get_channel(&mut self, channel_id: ChannelId) -> Option<Channel<Msg>> {
self.channels.lock().await.remove(&channel_id)
}
async fn llm_auth_decision(
&self,
username: &str,
auth_type: &str,
password: Option<&str>,
) -> Result<bool> {
let server_id = self
.server_id
.unwrap_or_else(|| crate::state::ServerId::new(1));
info!(
"SSH: llm_auth_decision() called for user '{}' via {}",
username, auth_type
);
debug!(
"SSH: llm_auth_decision - server_id={:?}, connection={}",
server_id, self.connection_id
);
Log::new(Some(&self.status_tx)).debug(format!(
"SSH: llm_auth_decision('{}', '{}')",
username, auth_type
));
let mut event_data = serde_json::json!({
"username": username,
"auth_type": auth_type,
});
if let Some(password) = password {
event_data["password"] = serde_json::Value::String(password.to_string());
}
let event = Event::new(&SSH_AUTH_EVENT, event_data);
match until_owner_drops(
&self.owner,
call_llm(
&self.llm_client,
&self.app_state,
server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
),
)
.await
{
Ok(result) => {
for protocol_result in result.protocol_results {
if let ActionResult::Custom { name, data } = protocol_result {
if name == "ssh_auth_decision" {
if let Some(allowed) = data.get("allowed").and_then(|v| v.as_bool()) {
let decision = if allowed {
"model_accept"
} else {
"model_reject"
};
Log::new(Some(&self.status_tx)).info(format!(
"SSH auth for '{}': decision={}",
username, decision
));
return Ok(allowed);
}
}
}
}
Log::new(Some(&self.status_tx)).warn(format!(
"SSH auth for '{}': decision=fail_closed_no_answer (handler returned no \
ssh_auth_decision)",
username
));
Ok(false)
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
warn!(
"SSH auth for '{}' on connection {}: decision=fail_closed_backend_error \
category={:?}: {}",
username, self.connection_id, failure, e
);
Log::new(Some(&self.status_tx)).warn(format!(
"SSH auth denied for '{}' on connection {}: \
decision=fail_closed_backend_error category={:?}",
username, self.connection_id, failure
));
Ok(false)
}
}
}
async fn llm_shell_banner(&self) -> Result<Option<String>> {
let server_id = self
.server_id
.unwrap_or_else(|| crate::state::ServerId::new(1));
debug!("SSH requesting shell banner from LLM");
let event = Event::new(&SSH_BANNER_EVENT, serde_json::json!({}));
match until_owner_drops(
&self.owner,
call_llm(
&self.llm_client,
&self.app_state,
server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
),
)
.await
{
Ok(result) => {
for protocol_result in result.protocol_results {
if let ActionResult::Output(data) = protocol_result {
let banner = String::from_utf8_lossy(&data).to_string();
let normalized = Self::normalize_line_endings(&banner);
debug!("SSH banner received: {} bytes", normalized.len());
return Ok(Some(normalized));
}
}
debug!("SSH banner: decision=no_answer (handler returned no banner)");
Ok(None)
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
warn!(
"SSH banner on connection {}: decision=backend_error category={:?}: {}",
self.connection_id, failure, e
);
Log::new(Some(&self.status_tx)).warn(format!(
"SSH banner unavailable on connection {}: decision=backend_error category={:?}",
self.connection_id, failure
));
Ok(None)
}
}
}
async fn llm_shell_command(
&self,
command: &[u8],
first_input: bool,
) -> Result<(Option<String>, bool)> {
let server_id = self
.server_id
.unwrap_or_else(|| crate::state::ServerId::new(1));
let command_str = String::from_utf8_lossy(command);
debug!("SSH shell command: {:?}", command_str);
trace!("SSH shell command (full): {}", command_str);
let mut control: Vec<&str> = Vec::new();
if command.contains(&0x03) {
control.push("ctrl_c");
}
if command.contains(&0x04) {
control.push("ctrl_d");
}
if command.contains(&0x1A) {
control.push("ctrl_z");
}
let empty_input = command.iter().all(|&b| b == b'\n' || b == b'\r');
let event = Event::new(
&SSH_SHELL_COMMAND_EVENT,
serde_json::json!({
"command": command_str.to_string(),
"first_input": first_input,
"empty_input": empty_input,
"control": control,
}),
);
match until_owner_drops(
&self.owner,
call_llm(
&self.llm_client,
&self.app_state,
server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
),
)
.await
{
Ok(result) => {
let mut output: Option<String> = None;
let mut close_connection = false;
for protocol_result in result.protocol_results {
match protocol_result {
ActionResult::Output(data) => {
let text = String::from_utf8_lossy(&data).to_string();
output = Some(Self::normalize_line_endings(&text));
}
ActionResult::CloseConnection => {
close_connection = true;
}
_ => {}
}
}
debug!(
"SSH shell response: output={}, close={}",
output.is_some(),
close_connection
);
Ok((output, close_connection))
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
warn!(
"SSH shell command on connection {}: decision=fail_closed_backend_error \
category={:?}: {}",
self.connection_id, failure, e
);
Log::new(Some(&self.status_tx)).warn(format!(
"SSH shell command failed on connection {}: \
decision=fail_closed_backend_error category={:?}",
self.connection_id, failure
));
Err(e)
}
}
}
}
#[async_trait]
impl russh::server::Handler for SshHandler {
type Error = anyhow::Error;
async fn auth_publickey(
&mut self,
user: &str,
_public_key: &russh_keys::key::PublicKey,
) -> Result<Auth, Self::Error> {
info!("SSH: auth_publickey() called for user '{}'", user);
debug!(
"SSH: Public key auth for user '{}', connection {}",
user, self.connection_id
);
Log::new(Some(&self.status_tx)).debug(format!("SSH: auth_publickey('{}') called", user));
let allowed = self.llm_auth_decision(user, "publickey", None).await?;
info!("SSH: auth_publickey result for '{}': {}", user, allowed);
if allowed {
Ok(Auth::Accept)
} else {
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
info!("SSH: auth_password() called for user '{}'", user);
debug!(
"SSH: Password auth for user '{}', connection {}",
user, self.connection_id
);
Log::new(Some(&self.status_tx)).debug(format!("SSH: auth_password('{}') called", user));
let allowed = self
.llm_auth_decision(user, "password", Some(password))
.await?;
info!("SSH: auth_password result for '{}': {}", user, allowed);
if allowed {
Ok(Auth::Accept)
} else {
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn channel_open_session(
&mut self,
channel: Channel<Msg>,
_session: &mut Session,
) -> Result<bool, Self::Error> {
if !self.config.shell_enabled {
debug!("SSH shell channel requested but shell is disabled");
return Ok(false);
}
let channel_id = channel.id();
self.channel_types
.lock()
.await
.insert(channel_id, ChannelType::Session);
self.channels.lock().await.insert(channel_id, channel);
debug!("SSH session channel {} opened", channel_id);
Ok(true)
}
async fn subsystem_request(
&mut self,
channel_id: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
Log::new(Some(&self.status_tx)).debug(format!(
"SSH request: SUBSYSTEM channel={}, name={}",
channel_id, name
));
Log::new(Some(&self.status_tx)).trace(format!(
"SSH SUBSYSTEM request: channel={}, name='{}', connection={}",
channel_id, name, self.connection_id
));
if name == "sftp" {
if !self.config.sftp_enabled {
Log::new(Some(&self.status_tx))
.warn("SFTP subsystem requested but SFTP is disabled");
Log::new(Some(&self.status_tx))
.debug("SSH response: CHANNEL_FAILURE (SFTP disabled)");
session.channel_failure(channel_id);
return Ok(());
}
self.channel_types
.lock()
.await
.insert(channel_id, ChannelType::Sftp);
Log::new(Some(&self.status_tx)).info(format!(
"SSH SFTP subsystem started on channel {} (connection {})",
channel_id, self.connection_id
));
if let Some(channel) = self.get_channel(channel_id).await {
Log::new(Some(&self.status_tx))
.debug("SSH response: CHANNEL_SUCCESS (starting SFTP handler)");
Log::new(Some(&self.status_tx)).trace(format!(
"Creating LlmSftpHandler for channel {} on connection {}",
channel_id, self.connection_id
));
session.channel_success(channel_id);
let server_id = self
.server_id
.unwrap_or_else(|| crate::state::ServerId::new(1));
let sftp_handler = crate::server::LlmSftpHandler::new(
self.connection_id,
server_id,
self.llm_client.clone(),
self.app_state.clone(),
self.protocol.clone(),
self.status_tx.clone(),
self.owner.clone(),
);
Log::new(Some(&self.status_tx)).trace(format!(
"Starting russh_sftp::server::run() for channel {}",
channel_id
));
russh_sftp::server::run(channel.into_stream(), sftp_handler).await;
Log::new(Some(&self.status_tx)).info(format!(
"SFTP session ended on channel {} (connection {})",
channel_id, self.connection_id
));
Log::new(Some(&self.status_tx)).debug(format!(
"SSH: SFTP subsystem terminated on channel {}",
channel_id
));
} else {
Log::new(Some(&self.status_tx))
.error(format!("SFTP channel {} not found", channel_id));
Log::new(Some(&self.status_tx))
.debug("SSH response: CHANNEL_FAILURE (channel not found)");
session.channel_failure(channel_id);
}
} else {
Log::new(Some(&self.status_tx)).warn(format!(
"Unknown subsystem requested: '{}' on channel {}",
name, channel_id
));
Log::new(Some(&self.status_tx)).debug(format!(
"SSH response: CHANNEL_FAILURE (unknown subsystem '{}')",
name
));
Log::new(Some(&self.status_tx)).trace(format!(
"SSH rejecting unknown subsystem: name='{}', channel={}, connection={}",
name, channel_id, self.connection_id
));
session.channel_failure(channel_id);
}
Ok(())
}
async fn shell_request(
&mut self,
channel_id: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
debug!("SSH shell request on channel {}", channel_id);
if !self.config.shell_enabled {
error!("Shell requested but shell is disabled");
session.channel_failure(channel_id);
return Ok(());
}
session.channel_success(channel_id);
if let Ok(Some(banner)) = self.llm_shell_banner().await {
let data = CryptoVec::from_slice(banner.as_bytes());
session.data(channel_id, data);
debug!("Sent shell banner ({} bytes)", banner.len());
}
Ok(())
}
async fn exec_request(
&mut self,
channel_id: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
let command = String::from_utf8_lossy(data);
debug!("SSH exec request on channel {}: {:?}", channel_id, command);
if !self.config.shell_enabled {
error!("Exec requested but shell is disabled");
session.channel_failure(channel_id);
return Ok(());
}
session.channel_success(channel_id);
let exit_status: u32 = match self.llm_shell_command(data, true).await {
Ok((output, _close)) => {
if let Some(output_text) = output {
let data = CryptoVec::from_slice(output_text.as_bytes());
session.data(channel_id, data);
debug!("Sent exec output ({} bytes)", output_text.len());
} else {
Log::new(Some(&self.status_tx)).debug(format!(
"SSH exec on channel {}: decision=model_answer_empty",
channel_id
));
}
0
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
Log::new(Some(&self.status_tx)).warn(format!(
"SSH exec on channel {}: decision=fail_closed_backend_error category={:?}",
channel_id, failure
));
let notice =
CryptoVec::from_slice(format!("{}\r\n", failure.prefixed_text()).as_bytes());
session.extended_data(channel_id, 1, notice);
if failure.is_overloaded() {
75
} else {
69
}
}
};
session.exit_status_request(channel_id, exit_status);
session.eof(channel_id);
self.close_channel_once(session, channel_id).await;
Ok(())
}
async fn data(
&mut self,
channel_id: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
let channel_types = self.channel_types.lock().await;
let channel_type = channel_types.get(&channel_id).cloned();
drop(channel_types);
match channel_type {
Some(ChannelType::Session) => {
Log::new(Some(&self.status_tx)).trace(format!(
"SSH shell data received on channel {}: hex={:02x?}",
channel_id, data
));
let mut buffers = self.shell_buffers.lock().await;
let buffer = buffers.entry(channel_id).or_insert_with(Vec::new);
if buffer.len().saturating_add(data.len()) > MAX_SHELL_LINE_BYTES {
buffer.clear();
drop(buffers);
tracing::error!(
"SSH shell line on channel {} exceeded {} bytes; discarded. \
decision=fail_closed_oversized_line",
channel_id,
MAX_SHELL_LINE_BYTES
);
Log::new(Some(&self.status_tx)).error(format!(
"SSH channel {} sent a shell line over {} bytes; discarded",
channel_id, MAX_SHELL_LINE_BYTES
));
let notice =
CryptoVec::from_slice(b"\r\n[netget] input line too long, discarded\r\n");
session.data(channel_id, notice);
return Ok(());
}
for &byte in data {
match byte {
0x7F | 0x08 => {
if !buffer.is_empty() {
buffer.pop();
let erase = CryptoVec::from_slice(&[0x08, b' ', 0x08]);
session.data(channel_id, erase);
trace!("SSH shell: backspace, buffer now {} bytes", buffer.len());
}
}
0x09 => {
let echo = CryptoVec::from_slice(&[byte]);
session.data(channel_id, echo);
}
b'\n' | b'\r' => {
let echo = CryptoVec::from_slice(b"\r\n");
session.data(channel_id, echo);
buffer.push(byte);
}
0x01..=0x1F => {
let ctrl_char = byte + 0x40;
let echo_str = format!("^{}\r\n", ctrl_char as char);
let echo = CryptoVec::from_slice(echo_str.as_bytes());
session.data(channel_id, echo);
buffer.push(byte);
}
0x20..=0x7E => {
let echo = CryptoVec::from_slice(&[byte]);
session.data(channel_id, echo);
buffer.push(byte);
}
_ => {
buffer.push(byte);
}
}
}
let should_process = data.iter().any(|&b| {
b == b'\n' || b == b'\r' || ((0x01..=0x1F).contains(&b) && b != 0x09)
});
if should_process {
let mut initialized = self.channel_initialized.lock().await;
let is_first_input = !initialized.get(&channel_id).copied().unwrap_or(false);
initialized.insert(channel_id, true);
drop(initialized);
let line = buffer.clone();
buffer.clear();
drop(buffers);
let has_ctrl_c = line.contains(&0x03);
let has_any_ctrl = line
.iter()
.any(|&b| (0x01..=0x1F).contains(&b) && b != 0x09);
let is_empty_cmd = line.iter().all(|&b| b == b'\n' || b == b'\r');
if is_first_input || !is_empty_cmd || has_any_ctrl {
Log::new(Some(&self.status_tx)).debug(format!(
"SSH shell processing input ({} bytes, first={}, empty={})",
line.len(),
is_first_input,
is_empty_cmd
));
Log::new(Some(&self.status_tx))
.trace(format!("SSH shell input (hex): {:02x?}", line));
Log::new(Some(&self.status_tx)).trace(format!(
"SSH shell input (text): {:?}",
String::from_utf8_lossy(&line)
));
let context = format!(
" [first_input={}, empty_input={}, ctrl_c={}]",
is_first_input, is_empty_cmd, has_ctrl_c
);
let command_result = self.llm_shell_command(&line, is_first_input).await;
if let Err(ref e) = command_result {
let failure = crate::utils::WireFailure::classify(e);
let description = failure.prefixed_text();
let notice = CryptoVec::from_slice(
format!("\r\n{}\r\n", description).as_bytes(),
);
session.data(channel_id, notice);
session.exit_status_request(
channel_id,
if failure.is_overloaded() { 75 } else { 69 },
);
session.eof(channel_id);
self.close_channel_once(session, channel_id).await;
session.disconnect(
russh::Disconnect::ServiceNotAvailable,
description,
"en",
);
Log::new(Some(&self.status_tx)).warn(format!(
"SSH disconnected channel {}: \
decision=fail_closed_backend_error category={:?}",
channel_id, failure
));
}
if let Ok((output, close_connection)) = command_result {
if let Some(output_text) = output {
let response = CryptoVec::from_slice(output_text.as_bytes());
session.data(channel_id, response);
Log::new(Some(&self.status_tx)).debug(format!(
"Sent shell response ({} bytes){}",
output_text.len(),
context
));
}
if close_connection {
Log::new(Some(&self.status_tx)).info(format!(
"LLM requested shell connection close on channel {}",
channel_id
));
session.exit_status_request(channel_id, 0);
session.eof(channel_id);
self.close_channel_once(session, channel_id).await;
} else {
let prompt = CryptoVec::from_slice(b"$ ");
session.data(channel_id, prompt);
}
}
} else {
Log::new(Some(&self.status_tx))
.trace("SSH shell: ignoring empty Enter (already initialized)");
}
} else {
Log::new(Some(&self.status_tx))
.trace(format!("SSH shell buffering: {} bytes total", buffer.len()));
}
}
Some(ChannelType::Sftp) => {
debug!(
"SFTP data received on channel {} - should be handled by SFTP subsystem",
channel_id
);
}
None => {
debug!("Data received on unknown channel {}", channel_id);
}
}
Ok(())
}
async fn channel_eof(
&mut self,
channel_id: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
debug!("SSH channel {} EOF", channel_id);
self.close_channel_once(session, channel_id).await;
Ok(())
}
async fn channel_close(
&mut self,
channel_id: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
debug!("SSH channel {} closed", channel_id);
self.channel_types.lock().await.remove(&channel_id);
self.channels.lock().await.remove(&channel_id);
self.shell_buffers.lock().await.remove(&channel_id);
self.channel_initialized.lock().await.remove(&channel_id);
self.channels_closed.lock().await.remove(&channel_id);
Ok(())
}
}