netget/server/ssh/mod.rs
1//! SSH server implementation using russh
2
3pub mod actions;
4pub mod sftp_handler;
5
6use crate::llm::action_helper::call_llm;
7use crate::llm::actions::protocol_trait::ActionResult;
8use crate::llm::ollama_client::OllamaClient;
9use crate::logging::emit::Log;
10use crate::protocol::Event;
11use crate::server::connection::ConnectionId;
12use crate::state::app_state::AppState;
13use actions::{SshProtocol, SSH_AUTH_EVENT, SSH_BANNER_EVENT, SSH_SHELL_COMMAND_EVENT};
14use anyhow::{anyhow, Result};
15use async_trait::async_trait;
16use russh::server::{Auth, Msg, Session};
17use russh::{Channel, ChannelId, CryptoVec};
18use russh_keys::key::KeyPair;
19use std::collections::{HashMap, HashSet};
20use std::net::SocketAddr;
21use std::sync::Arc;
22use tokio::sync::{mpsc, Mutex};
23use tokio_util::sync::CancellationToken;
24use tracing::{debug, error, info, trace, warn};
25
26/// Largest shell line the server will accumulate on one channel before discarding it.
27///
28/// This is the only inbound buffer NetGet owns in this protocol: russh does the transport
29/// framing and bounds a single packet, but the per-channel echo buffer is flushed to the model
30/// only on a newline or a control byte, so it accumulates across arbitrarily many packets.
31///
32/// 64 KiB is a policy choice rather than a spec number — SSH defines no line limit. It is far
33/// above any command a person types or a script sends, and far below anything worth holding
34/// per channel. Note the buffer becomes an LLM prompt, where everything past a few kilobytes
35/// is cost with no benefit.
36pub const MAX_SHELL_LINE_BYTES: usize = 64 * 1024;
37
38/// How long to wait for a peer's first byte after it connects.
39///
40/// Both ends send an identification string as soon as the TCP connection is up (RFC 4253 §4.2),
41/// so a peer that has connected and sent nothing has begun no session at all — which is the
42/// state an unauthenticated flood lives in. OpenSSH's own `LoginGraceTime` for *completing*
43/// authentication is 120 seconds; this is half that and applies only to producing a single byte,
44/// so it cannot close a peer `sshd` would keep.
45///
46/// Enforced by [`DeadlinedStream`] rather than by a `peek` before russh sees the socket,
47/// deliberately: SSH is the one protocol in this sweep where the *server* may legitimately speak
48/// first, and a `peek` would make the bound depend on the client speaking first, which the RFC
49/// does not require.
50const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
51
52/// How long a session may do nothing at all once it is up.
53///
54/// **An interactive shell is legitimately silent for a long time**, and SSH has no keepalive
55/// that is on by default to sit above: OpenSSH's `ServerAliveInterval` and `ClientAliveInterval`
56/// both default to **0**, i.e. disabled. An hour is therefore the most aggressive bound that is
57/// defensible, and it is also what this server was already doing — russh's `inactivity_timeout`
58/// was set to 3600 here as an unexplained literal. It is now named, argued, and passed to *both*
59/// russh's config and [`DeadlinedStream`], so the two cannot drift apart.
60///
61/// The bound cannot fire during a model round-trip or a `manual` rule parked for a human: the
62/// reader's deadline is armed lazily, only while a read is pending with no bytes yet, and russh
63/// awaits a handler inside the arm that matched rather than polling the read alongside it. See
64/// `IdleTimeoutReader`'s own documentation — that lazy arming is the whole correctness argument,
65/// and is the TFTP live-transfer eviction read in reverse.
66const IDLE_SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
67
68/// Concurrent connections this server admits.
69///
70/// Each admitted connection costs a russh session with its own transport state, cipher buffers
71/// and per-channel echo buffers of up to [`MAX_SHELL_LINE_BYTES`], all before any password has
72/// been checked, so the cap is what turns those per-connection bounds into a total one.
73const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
74
75/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
76///
77/// The exact line OpenSSH's `sshd` writes when it refuses a connection over `MaxStartups`, and
78/// it is legal SSH: RFC 4253 §4.2 lets a server send lines of data *before* its identification
79/// string precisely so it can say something to the user, and a client prints them. So `ssh`
80/// reports `Exceeded MaxStartups` rather than an unexplained reset.
81///
82/// One honest difference from `sshd`: this cap counts **every** connection, not only
83/// unauthenticated ones, so it is not literally MaxStartups. The text is chosen because it is
84/// the line SSH clients and their users already recognise for "this server is refusing new
85/// connections right now", which is exactly what happened.
86const CONNECTION_CAP_REFUSAL: &[u8] = b"Exceeded MaxStartups\r\n";
87
88/// A `TcpStream` whose **reads** are bounded in time, with writes passed straight through.
89///
90/// russh owns the session loop once `run_stream` is called, so there is no `read()` of ours to
91/// wrap — the shape every other protocol in this sweep uses. `IdleTimeoutReader` exists for
92/// exactly that case; this type is the two-line adapter that lets russh, which wants one
93/// `AsyncRead + AsyncWrite`, take a reader with a deadline on it.
94///
95/// It also carries the connection owner's [`CancellationToken`]. russh spawns its session
96/// driver inside `run_stream` and hands back only a `RunningSession` whose `JoinHandle` does
97/// not abort on drop, so aborting the task that owns the connection — which is what
98/// `stop_server` does — cannot reach the driver directly. Every read and write therefore polls
99/// the token first: once the owner is gone the next poll fails, and a read already parked on
100/// the socket is woken by the token rather than waiting for the peer or the idle deadline.
101struct DeadlinedStream {
102 reader: crate::server::accept_bounded::IdleTimeoutReader<
103 tokio::io::ReadHalf<tokio::net::TcpStream>,
104 >,
105 writer: tokio::io::WriteHalf<tokio::net::TcpStream>,
106 owner: CancellationToken,
107 owner_gone: std::pin::Pin<Box<tokio_util::sync::WaitForCancellationFutureOwned>>,
108}
109
110impl DeadlinedStream {
111 fn new(
112 stream: tokio::net::TcpStream,
113 first: std::time::Duration,
114 idle: std::time::Duration,
115 owner: CancellationToken,
116 ) -> Self {
117 let (read_half, writer) = tokio::io::split(stream);
118 Self {
119 reader: crate::server::accept_bounded::IdleTimeoutReader::with_first(
120 read_half, first, idle,
121 ),
122 writer,
123 owner_gone: Box::pin(owner.clone().cancelled_owned()),
124 owner,
125 }
126 }
127
128 /// `Some` once the connection's owner has dropped. Polling the owner future registers
129 /// this task's waker, which is what wakes a read parked on a quiet socket.
130 fn owner_dropped(&mut self, cx: &mut std::task::Context<'_>) -> Option<std::io::Error> {
131 let gone = self.owner.is_cancelled()
132 || std::future::Future::poll(self.owner_gone.as_mut(), cx).is_ready();
133 gone.then(|| {
134 std::io::Error::new(
135 std::io::ErrorKind::ConnectionAborted,
136 "SSH connection owner dropped",
137 )
138 })
139 }
140}
141
142impl tokio::io::AsyncRead for DeadlinedStream {
143 fn poll_read(
144 mut self: std::pin::Pin<&mut Self>,
145 cx: &mut std::task::Context<'_>,
146 buf: &mut tokio::io::ReadBuf<'_>,
147 ) -> std::task::Poll<std::io::Result<()>> {
148 if let Some(e) = self.owner_dropped(cx) {
149 return std::task::Poll::Ready(Err(e));
150 }
151 std::pin::Pin::new(&mut self.reader).poll_read(cx, buf)
152 }
153}
154
155impl tokio::io::AsyncWrite for DeadlinedStream {
156 fn poll_write(
157 mut self: std::pin::Pin<&mut Self>,
158 cx: &mut std::task::Context<'_>,
159 buf: &[u8],
160 ) -> std::task::Poll<std::io::Result<usize>> {
161 if let Some(e) = self.owner_dropped(cx) {
162 return std::task::Poll::Ready(Err(e));
163 }
164 std::pin::Pin::new(&mut self.writer).poll_write(cx, buf)
165 }
166
167 fn poll_flush(
168 mut self: std::pin::Pin<&mut Self>,
169 cx: &mut std::task::Context<'_>,
170 ) -> std::task::Poll<std::io::Result<()>> {
171 if let Some(e) = self.owner_dropped(cx) {
172 return std::task::Poll::Ready(Err(e));
173 }
174 std::pin::Pin::new(&mut self.writer).poll_flush(cx)
175 }
176
177 fn poll_shutdown(
178 mut self: std::pin::Pin<&mut Self>,
179 cx: &mut std::task::Context<'_>,
180 ) -> std::task::Poll<std::io::Result<()>> {
181 if let Some(e) = self.owner_dropped(cx) {
182 return std::task::Poll::Ready(Err(e));
183 }
184 std::pin::Pin::new(&mut self.writer).poll_shutdown(cx)
185 }
186}
187
188/// Await a model round-trip unless the connection's owner goes first.
189///
190/// Dropping `call` is what retires a `manual` intercept parked for a human: its waiter goes
191/// with the future, and the pending entry reads as dead from then on.
192pub(crate) async fn until_owner_drops<T>(
193 owner: &CancellationToken,
194 call: impl std::future::Future<Output = Result<T>>,
195) -> Result<T> {
196 tokio::select! {
197 biased;
198 _ = owner.cancelled() => Err(anyhow::anyhow!(
199 "SSH connection closed by its owner before the answer arrived"
200 )),
201 result = call => result,
202 }
203}
204
205/// SSH server configuration
206#[derive(Clone, Debug)]
207pub struct SshServerConfig {
208 /// Enable shell channel support
209 pub shell_enabled: bool,
210 /// Enable SFTP subsystem support
211 pub sftp_enabled: bool,
212}
213
214impl Default for SshServerConfig {
215 fn default() -> Self {
216 Self {
217 shell_enabled: true,
218 sftp_enabled: true,
219 }
220 }
221}
222
223/// SSH server implementation
224pub struct SshServer {
225 _config: SshServerConfig,
226 _llm_client: OllamaClient,
227 _app_state: Arc<AppState>,
228 _status_tx: mpsc::UnboundedSender<String>,
229 _server_id: Option<crate::state::ServerId>,
230}
231
232impl SshServer {
233 /// Create a new SSH server
234 pub fn new(
235 config: SshServerConfig,
236 llm_client: OllamaClient,
237 app_state: Arc<AppState>,
238 status_tx: mpsc::UnboundedSender<String>,
239 server_id: Option<crate::state::ServerId>,
240 ) -> Self {
241 Self {
242 _config: config,
243 _llm_client: llm_client,
244 _app_state: app_state,
245 _status_tx: status_tx,
246 _server_id: server_id,
247 }
248 }
249
250 /// Spawn SSH server with LLM integration
251 pub async fn spawn_with_llm(
252 listen_addr: SocketAddr,
253 llm_client: OllamaClient,
254 app_state: Arc<AppState>,
255 status_tx: mpsc::UnboundedSender<String>,
256 ) -> Result<SocketAddr> {
257 let config = SshServerConfig::default();
258 Self::spawn_with_config(listen_addr, config, llm_client, app_state, status_tx, None).await
259 }
260
261 /// Spawn SSH server with custom configuration
262 pub async fn spawn_with_config(
263 listen_addr: SocketAddr,
264 config: SshServerConfig,
265 llm_client: OllamaClient,
266 app_state: Arc<AppState>,
267 status_tx: mpsc::UnboundedSender<String>,
268 server_id: Option<crate::state::ServerId>,
269 ) -> Result<SocketAddr> {
270 // Generate host key
271 let key_pair = generate_host_key()?;
272
273 let russh_config = russh::server::Config {
274 // The same constant `DeadlinedStream` is built with, so russh's own view of an
275 // idle session and ours cannot drift apart.
276 inactivity_timeout: Some(IDLE_SESSION_TIMEOUT),
277 auth_rejection_time: std::time::Duration::from_secs(3),
278 auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
279 keys: vec![key_pair],
280 ..Default::default()
281 };
282
283 let russh_config = Arc::new(russh_config);
284
285 // Bind TCP listener. SO_REUSEADDR matches the other TCP protocols, so restarting a
286 // server on the same port does not fail with EADDRINUSE while the old socket lingers.
287 let listener =
288 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
289 let actual_addr = listener.local_addr()?;
290
291 Log::new(Some(&status_tx)).info(format!(
292 "SSH server listening on {} (shell: {}, sftp: {})",
293 actual_addr, config.shell_enabled, config.sftp_enabled
294 ));
295
296 // Spawn accept loop
297 let task_registrar = app_state.clone();
298 let accept_handle = tokio::spawn(async move {
299 Log::new(Some(&status_tx))
300 .debug(format!("SSH: Accept loop started on {}", actual_addr));
301
302 // Counter for connection IDs
303 let mut connection_counter = 0u64;
304 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
305
306 loop {
307 match crate::server::accept_bounded::accept_bounded(
308 &listener,
309 &limiter,
310 CONNECTION_CAP_REFUSAL,
311 "SSH",
312 Some(&status_tx),
313 )
314 .await
315 {
316 Ok((tcp_stream, peer_addr, permit)) => {
317 connection_counter += 1;
318 Log::new(Some(&status_tx)).info(format!(
319 "SSH: Accepted TCP connection #{} from {}",
320 connection_counter, peer_addr
321 ));
322 debug!("SSH: Creating handler for connection from {}", peer_addr);
323
324 // Get next connection ID
325 let connection_id =
326 ConnectionId::new(app_state.get_next_unified_id().await);
327
328 // Track connection in server state
329 if let Some(server_id_val) = server_id {
330 use crate::state::server::{
331 ConnectionState as ServerConnectionState, ConnectionStatus,
332 ProtocolConnectionInfo,
333 };
334 let now = crate::utils::clock::Instant::now();
335
336 let conn_state = ServerConnectionState {
337 id: connection_id,
338 remote_addr: peer_addr,
339 local_addr: actual_addr,
340 bytes_sent: 0,
341 bytes_received: 0,
342 packets_sent: 0,
343 packets_received: 0,
344 last_activity: now,
345 status: ConnectionStatus::Active,
346 status_changed_at: now,
347 protocol_info: ProtocolConnectionInfo::empty(),
348 };
349
350 let app_state_clone = app_state.clone();
351 let status_tx_clone = status_tx.clone();
352
353 // Tracked, not detached: stop_server must abort this task too.
354 let task_owner = app_state.clone();
355 task_owner
356 .spawn_server_task(server_id_val, async move {
357 app_state_clone
358 .add_connection_to_server(server_id_val, conn_state)
359 .await;
360 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
361 })
362 .await;
363 }
364
365 // Create handler for this connection
366 let owner = CancellationToken::new();
367 let handler = SshHandler::new(
368 connection_id,
369 config.clone(),
370 llm_client.clone(),
371 app_state.clone(),
372 status_tx.clone(),
373 server_id,
374 Some(peer_addr),
375 owner.clone(),
376 );
377
378 let config_clone = russh_config.clone();
379 let status_tx_clone = status_tx.clone();
380
381 // Spawn connection handler
382 let app_state_close = app_state.clone();
383 // Tracked, not detached: stop_server must abort this task too. SSH's
384 // `server_id` is an `Option`, so the untracked spawn stays as the
385 // fallback for a caller with no server to own the connection.
386 let task_owner = app_state.clone();
387 let ssh_connection = async move {
388 {
389 // Held for the life of the connection: dropping it early
390 // releases the slot while the peer is still here, which
391 // silently un-caps the server.
392 let _permit = permit;
393 // Declared after the permit so it drops first: when this task
394 // ends or is aborted, russh's spawned driver and every parked
395 // model call are told before the slot is released.
396 let _cancel_on_drop = owner.clone().drop_guard();
397
398 Log::new(Some(&status_tx_clone))
399 .debug(format!("SSH: Starting SSH protocol for {}", peer_addr));
400
401 // russh owns every read from here on, so the deadline goes on
402 // the stream rather than around a `read()` of ours. It is armed
403 // lazily — only while a read is pending with no bytes — so a
404 // model round-trip, or a `manual` rule parked for a human, runs
405 // with no clock against it.
406 let tcp_stream = DeadlinedStream::new(
407 tcp_stream,
408 FIRST_BYTE_READ_TIMEOUT,
409 IDLE_SESSION_TIMEOUT,
410 owner,
411 );
412
413 // `run_stream` returns once the identification strings are
414 // exchanged and the driver is spawned; the session itself is
415 // the `RunningSession` it hands back. Awaiting only the first
416 // released the permit and marked the connection closed while
417 // the peer was still logged in.
418 let session =
419 russh::server::run_stream(config_clone, tcp_stream, handler)
420 .await;
421 let outcome = match session {
422 Ok(running) => running.await,
423 Err(e) => Err(e),
424 };
425 match outcome {
426 Ok(_) => {
427 Log::new(Some(&status_tx_clone))
428 .info(format!("SSH: Connection closed: {}", peer_addr));
429 }
430 Err(e) => {
431 Log::new(Some(&status_tx_clone)).error(format!(
432 "SSH: Connection error from {}: {}",
433 peer_addr, e
434 ));
435 }
436 }
437
438 // Mark the connection closed in AppState. Without this the entry
439 // added above stays `Active` for the life of the server: the rail
440 // showed every SSH session that had ever connected as still live,
441 // and a connection-scoped scheduled task on a session that ended
442 // hours ago was never cleaned up. SSH is connection-oriented, so
443 // the 10-second idle sweep does not run over it and cannot
444 // compensate — this is the only place the entry can be retired.
445 if let Some(server_id_val) = server_id {
446 app_state_close
447 .close_connection_on_server(server_id_val, connection_id)
448 .await;
449 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
450 }
451 }
452 };
453 match server_id {
454 Some(sid) => {
455 task_owner.spawn_server_task(sid, ssh_connection).await;
456 }
457 None => {
458 tokio::spawn(ssh_connection);
459 }
460 }
461 }
462 Err(e) => {
463 Log::new(Some(&status_tx)).error(format!("SSH: Accept error: {}", e));
464 break;
465 }
466 }
467 }
468
469 debug!("SSH: Accept loop ended");
470 });
471
472 // Register the accept loop so stop_server can abort it and release the port.
473 if let Some(server_id_val) = server_id {
474 task_registrar
475 .register_server_task(server_id_val, accept_handle)
476 .await;
477 }
478
479 Ok(actual_addr)
480 }
481
482 /// Spawn SSH server with action-based LLM integration
483 pub async fn spawn_with_llm_actions(
484 listen_addr: SocketAddr,
485 llm_client: OllamaClient,
486 app_state: Arc<AppState>,
487 status_tx: mpsc::UnboundedSender<String>,
488 server_id: crate::state::ServerId,
489 ) -> Result<SocketAddr> {
490 let config = SshServerConfig::default();
491 Self::spawn_with_config(
492 listen_addr,
493 config,
494 llm_client,
495 app_state,
496 status_tx,
497 Some(server_id),
498 )
499 .await
500 }
501}
502
503/// Generate a host key for the SSH server
504fn generate_host_key() -> Result<KeyPair> {
505 // Generate an Ed25519 key pair
506 let key =
507 KeyPair::generate_ed25519().ok_or_else(|| anyhow!("Failed to generate Ed25519 key"))?;
508 Ok(key)
509}
510
511/// SSH session handler
512pub struct SshHandler {
513 connection_id: ConnectionId,
514 config: SshServerConfig,
515 llm_client: OllamaClient,
516 app_state: Arc<AppState>,
517 status_tx: mpsc::UnboundedSender<String>,
518 #[allow(dead_code)] // Used for connection tracking in new_client, not in handler methods
519 server_id: Option<crate::state::ServerId>,
520 #[allow(dead_code)] // Stored for future use (e.g., logging peer address in errors)
521 remote_addr: Option<SocketAddr>,
522 /// SSH protocol handler for action execution
523 protocol: Arc<SshProtocol>,
524 /// Active channels and their types
525 channel_types: Arc<Mutex<HashMap<ChannelId, ChannelType>>>,
526 /// Active channel objects (for SFTP)
527 channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
528 /// Input buffers for shell channels (accumulate until newline)
529 shell_buffers: Arc<Mutex<HashMap<ChannelId, Vec<u8>>>>,
530 /// Track if we've sent initial data for each channel (for banner vs empty enter)
531 channel_initialized: Arc<Mutex<HashMap<ChannelId, bool>>>,
532 /// Channels this side has already sent `SSH_MSG_CHANNEL_CLOSE` for.
533 ///
534 /// RFC 4254 §5.3 lets each party send `SSH_MSG_CHANNEL_CLOSE` **once**. Without this set
535 /// an exec channel got two: `exec_request` sends exit-status + EOF + CLOSE when the answer
536 /// is ready, and then the client's own EOF arrives and `channel_eof` sent a second CLOSE.
537 /// See `close_channel_once`.
538 channels_closed: Arc<Mutex<HashSet<ChannelId>>>,
539 /// Cancelled when the task owning this connection ends or is aborted; every model
540 /// round-trip, including a `manual` rule parked for a human, races it.
541 owner: CancellationToken,
542}
543
544/// Type of SSH channel
545#[derive(Debug, Clone)]
546enum ChannelType {
547 Session,
548 Sftp,
549}
550
551impl SshHandler {
552 fn new(
553 connection_id: ConnectionId,
554 config: SshServerConfig,
555 llm_client: OllamaClient,
556 app_state: Arc<AppState>,
557 status_tx: mpsc::UnboundedSender<String>,
558 server_id: Option<crate::state::ServerId>,
559 remote_addr: Option<SocketAddr>,
560 owner: CancellationToken,
561 ) -> Self {
562 Self {
563 connection_id,
564 config,
565 llm_client,
566 app_state,
567 status_tx,
568 server_id,
569 remote_addr,
570 protocol: Arc::new(SshProtocol::new()),
571 channel_types: Arc::new(Mutex::new(HashMap::new())),
572 channels: Arc::new(Mutex::new(HashMap::new())),
573 shell_buffers: Arc::new(Mutex::new(HashMap::new())),
574 channel_initialized: Arc::new(Mutex::new(HashMap::new())),
575 channels_closed: Arc::new(Mutex::new(HashSet::new())),
576 owner,
577 }
578 }
579
580 /// Send `SSH_MSG_CHANNEL_CLOSE` for this channel, at most once.
581 ///
582 /// RFC 4254 §5.3: "each side ... MUST send an SSH_MSG_CHANNEL_CLOSE message" — one, after
583 /// which "the channel is considered closed for a party" and the number may be reused.
584 /// Sending a second one is not a harmless duplicate: by the time it arrives the peer has
585 /// usually freed the channel, so the message refers to a channel number that no longer
586 /// exists.
587 ///
588 /// **OpenSSH treats that as fatal and disconnects**, which is how this was found — by the
589 /// real `ssh` binary in `tests/server/ssh/real_client_test.rs`, failing about one run in
590 /// five with
591 ///
592 /// ```text
593 /// Disconnecting 127.0.0.1 port N: oclose packet referred to nonexistent channel 0
594 /// ```
595 ///
596 /// and exit 255, *after* the command's output and `exit-status 0` had already arrived
597 /// intact. libssh2 — the only client that had ever driven this server — ignores the
598 /// second CLOSE, so nothing in the rest of the suite could see it.
599 ///
600 /// The race is whether the peer has garbage-collected the channel yet: the client sends
601 /// its EOF at about the same moment the server answers, so `channel_eof` fired either
602 /// before the client freed the channel (harmless, `close rcvd twice` on stderr) or after
603 /// it (fatal).
604 async fn close_channel_once(&self, session: &mut Session, channel_id: ChannelId) {
605 if self.channels_closed.lock().await.insert(channel_id) {
606 session.close(channel_id);
607 } else {
608 debug!(
609 "SSH channel {} already closed by this side; not sending a second SSH_MSG_CHANNEL_CLOSE",
610 channel_id
611 );
612 }
613 }
614
615 /// Convert line endings to SSH/terminal format (\r\n)
616 /// This is required for proper display in SSH terminals.
617 ///
618 /// Handles both Unix (\n) and Windows (\r\n) line endings by:
619 /// 1. First normalizing to Unix (\n) - removes any existing \r
620 /// 2. Then converting all \n to \r\n
621 ///
622 /// This ensures consistent output regardless of what the LLM generates.
623 fn normalize_line_endings(text: &str) -> String {
624 // First normalize to Unix line endings, then convert to SSH format
625 // This prevents double \r\r\n if LLM already sends \r\n
626 text.replace("\r\n", "\n").replace('\n', "\r\n")
627 }
628
629 /// Get a channel object from the internal storage
630 async fn get_channel(&mut self, channel_id: ChannelId) -> Option<Channel<Msg>> {
631 self.channels.lock().await.remove(&channel_id)
632 }
633
634 /// Ask the handler/LLM whether to accept a login.
635 ///
636 /// `auth_type` is exactly "password" or "publickey" so that script and static handlers can
637 /// match on it. The password, when there is one, travels in its own field: it used to be
638 /// formatted into `auth_type` ("password (user='x', password='y')"), which both broke every
639 /// handler comparing `auth_type == "password"` and contradicted the documented parameter.
640 async fn llm_auth_decision(
641 &self,
642 username: &str,
643 auth_type: &str,
644 password: Option<&str>,
645 ) -> Result<bool> {
646 let server_id = self
647 .server_id
648 .unwrap_or_else(|| crate::state::ServerId::new(1));
649 info!(
650 "SSH: llm_auth_decision() called for user '{}' via {}",
651 username, auth_type
652 );
653 debug!(
654 "SSH: llm_auth_decision - server_id={:?}, connection={}",
655 server_id, self.connection_id
656 );
657 Log::new(Some(&self.status_tx)).debug(format!(
658 "SSH: llm_auth_decision('{}', '{}')",
659 username, auth_type
660 ));
661
662 // Create event with auth data
663 let mut event_data = serde_json::json!({
664 "username": username,
665 "auth_type": auth_type,
666 });
667 if let Some(password) = password {
668 event_data["password"] = serde_json::Value::String(password.to_string());
669 }
670 let event = Event::new(&SSH_AUTH_EVENT, event_data);
671
672 match until_owner_drops(
673 &self.owner,
674 call_llm(
675 &self.llm_client,
676 &self.app_state,
677 server_id,
678 Some(self.connection_id),
679 &event,
680 self.protocol.as_ref(),
681 ),
682 )
683 .await
684 {
685 Ok(result) => {
686 // Look for Custom result with auth decision
687 for protocol_result in result.protocol_results {
688 if let ActionResult::Custom { name, data } = protocol_result {
689 if name == "ssh_auth_decision" {
690 if let Some(allowed) = data.get("allowed").and_then(|v| v.as_bool()) {
691 // Three outcomes reach the same SSH_MSG_USERAUTH_FAILURE on the
692 // wire, so the `decision=` tag is the only thing that tells an
693 // operator which one happened. Keep the three distinct, the way
694 // `src/server/radius/` does: an explicit denial, silence, and a
695 // backend failure are different incidents.
696 let decision = if allowed {
697 "model_accept"
698 } else {
699 "model_reject"
700 };
701 Log::new(Some(&self.status_tx)).info(format!(
702 "SSH auth for '{}': decision={}",
703 username, decision
704 ));
705 return Ok(allowed);
706 }
707 }
708 }
709 }
710
711 // If no auth decision found, deny by default. Non-fatal: the client
712 // gets a real SSH_MSG_USERAUTH_FAILURE, so this is a WARN, not an error.
713 Log::new(Some(&self.status_tx)).warn(format!(
714 "SSH auth for '{}': decision=fail_closed_no_answer (handler returned no \
715 ssh_auth_decision)",
716 username
717 ));
718 Ok(false)
719 }
720 Err(e) => {
721 // Deny. This is the fail-closed branch and it must stay that way: an
722 // unreachable backend is not consent, and the client gets a real
723 // SSH_MSG_USERAUTH_FAILURE rather than a hung authentication. Non-fatal
724 // (the refusal is a real wire answer), so WARN rather than ERROR.
725 //
726 // The error is classified, never rendered to the peer — SSH has no way to
727 // explain an auth failure anyway, so the category lives only in the log.
728 let failure = crate::utils::WireFailure::classify(&e);
729 warn!(
730 "SSH auth for '{}' on connection {}: decision=fail_closed_backend_error \
731 category={:?}: {}",
732 username, self.connection_id, failure, e
733 );
734 Log::new(Some(&self.status_tx)).warn(format!(
735 "SSH auth denied for '{}' on connection {}: \
736 decision=fail_closed_backend_error category={:?}",
737 username, self.connection_id, failure
738 ));
739 Ok(false)
740 }
741 }
742 }
743
744 /// Ask LLM for shell banner/greeting using action-based framework
745 async fn llm_shell_banner(&self) -> Result<Option<String>> {
746 let server_id = self
747 .server_id
748 .unwrap_or_else(|| crate::state::ServerId::new(1));
749
750 debug!("SSH requesting shell banner from LLM");
751
752 // Create banner event with no data
753 let event = Event::new(&SSH_BANNER_EVENT, serde_json::json!({}));
754
755 match until_owner_drops(
756 &self.owner,
757 call_llm(
758 &self.llm_client,
759 &self.app_state,
760 server_id,
761 Some(self.connection_id),
762 &event,
763 self.protocol.as_ref(),
764 ),
765 )
766 .await
767 {
768 Ok(result) => {
769 // Look for Output result with banner data
770 for protocol_result in result.protocol_results {
771 if let ActionResult::Output(data) = protocol_result {
772 let banner = String::from_utf8_lossy(&data).to_string();
773 // Convert \n to \r\n for proper SSH terminal display
774 let normalized = Self::normalize_line_endings(&banner);
775 debug!("SSH banner received: {} bytes", normalized.len());
776 return Ok(Some(normalized));
777 }
778 }
779
780 // No banner in results
781 debug!("SSH banner: decision=no_answer (handler returned no banner)");
782 Ok(None)
783 }
784 Err(e) => {
785 // A missing banner is cosmetic - the shell still opens and the server writes
786 // its own "$ " prompt, so the peer is not left waiting. Nothing derived from
787 // the error is shown to the peer; only the log sees it. Non-fatal, so WARN.
788 let failure = crate::utils::WireFailure::classify(&e);
789 warn!(
790 "SSH banner on connection {}: decision=backend_error category={:?}: {}",
791 self.connection_id, failure, e
792 );
793 Log::new(Some(&self.status_tx)).warn(format!(
794 "SSH banner unavailable on connection {}: decision=backend_error category={:?}",
795 self.connection_id, failure
796 ));
797 Ok(None)
798 }
799 }
800 }
801
802 /// Ask LLM to handle shell command using action-based framework
803 /// Returns (output, close_connection)
804 ///
805 /// `first_input` says whether this is the opening Enter of the session. The control-key
806 /// flags are derived here so a handler can branch on Ctrl-C/Ctrl-D without scanning the
807 /// raw bytes; previously they were computed only for a log line, while the prompt claimed
808 /// the model would "see CTRL_C in the context flags".
809 async fn llm_shell_command(
810 &self,
811 command: &[u8],
812 first_input: bool,
813 ) -> Result<(Option<String>, bool)> {
814 let server_id = self
815 .server_id
816 .unwrap_or_else(|| crate::state::ServerId::new(1));
817 let command_str = String::from_utf8_lossy(command);
818
819 debug!("SSH shell command: {:?}", command_str);
820 trace!("SSH shell command (full): {}", command_str);
821
822 let mut control: Vec<&str> = Vec::new();
823 if command.contains(&0x03) {
824 control.push("ctrl_c");
825 }
826 if command.contains(&0x04) {
827 control.push("ctrl_d");
828 }
829 if command.contains(&0x1A) {
830 control.push("ctrl_z");
831 }
832 let empty_input = command.iter().all(|&b| b == b'\n' || b == b'\r');
833
834 // Create shell command event with command data
835 let event = Event::new(
836 &SSH_SHELL_COMMAND_EVENT,
837 serde_json::json!({
838 "command": command_str.to_string(),
839 "first_input": first_input,
840 "empty_input": empty_input,
841 "control": control,
842 }),
843 );
844
845 match until_owner_drops(
846 &self.owner,
847 call_llm(
848 &self.llm_client,
849 &self.app_state,
850 server_id,
851 Some(self.connection_id),
852 &event,
853 self.protocol.as_ref(),
854 ),
855 )
856 .await
857 {
858 Ok(result) => {
859 let mut output: Option<String> = None;
860 let mut close_connection = false;
861
862 // Process all protocol results
863 for protocol_result in result.protocol_results {
864 match protocol_result {
865 ActionResult::Output(data) => {
866 let text = String::from_utf8_lossy(&data).to_string();
867 // Convert \n to \r\n for proper SSH terminal display
868 output = Some(Self::normalize_line_endings(&text));
869 }
870 ActionResult::CloseConnection => {
871 close_connection = true;
872 }
873 _ => {}
874 }
875 }
876
877 debug!(
878 "SSH shell response: output={}, close={}",
879 output.is_some(),
880 close_connection
881 );
882
883 Ok((output, close_connection))
884 }
885 Err(e) => {
886 // Propagate instead of returning `Ok((None, false))`.
887 //
888 // That old value was worse than silence: the caller's `if let Ok(..)` matched
889 // it, wrote no output, and then wrote the "$ " prompt anyway — so a backend
890 // outage was indistinguishable from a command that ran successfully and
891 // printed nothing. The caller now sends an SSH disconnect with a reason code
892 // instead, which is a real answer the client reports to the user. Non-fatal
893 // (the disconnect is a defined wire response), so WARN rather than ERROR.
894 //
895 // The error is logged in full here and nowhere else; the callers get it only
896 // to classify it, and put a `&'static str` category on the wire.
897 let failure = crate::utils::WireFailure::classify(&e);
898 warn!(
899 "SSH shell command on connection {}: decision=fail_closed_backend_error \
900 category={:?}: {}",
901 self.connection_id, failure, e
902 );
903 Log::new(Some(&self.status_tx)).warn(format!(
904 "SSH shell command failed on connection {}: \
905 decision=fail_closed_backend_error category={:?}",
906 self.connection_id, failure
907 ));
908 Err(e)
909 }
910 }
911 }
912}
913
914// Note: We no longer implement russh::server::Server trait
915// Instead, we manually accept TCP connections and call russh::server::run_stream()
916
917#[async_trait]
918impl russh::server::Handler for SshHandler {
919 type Error = anyhow::Error;
920
921 async fn auth_publickey(
922 &mut self,
923 user: &str,
924 _public_key: &russh_keys::key::PublicKey,
925 ) -> Result<Auth, Self::Error> {
926 info!("SSH: auth_publickey() called for user '{}'", user);
927 debug!(
928 "SSH: Public key auth for user '{}', connection {}",
929 user, self.connection_id
930 );
931 Log::new(Some(&self.status_tx)).debug(format!("SSH: auth_publickey('{}') called", user));
932
933 // Ask LLM if this user should be allowed
934 let allowed = self.llm_auth_decision(user, "publickey", None).await?;
935
936 info!("SSH: auth_publickey result for '{}': {}", user, allowed);
937 if allowed {
938 Ok(Auth::Accept)
939 } else {
940 Ok(Auth::Reject {
941 proceed_with_methods: None,
942 })
943 }
944 }
945
946 async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
947 info!("SSH: auth_password() called for user '{}'", user);
948 debug!(
949 "SSH: Password auth for user '{}', connection {}",
950 user, self.connection_id
951 );
952 Log::new(Some(&self.status_tx)).debug(format!("SSH: auth_password('{}') called", user));
953
954 // Ask LLM if this user/password should be allowed
955 let allowed = self
956 .llm_auth_decision(user, "password", Some(password))
957 .await?;
958
959 info!("SSH: auth_password result for '{}': {}", user, allowed);
960 if allowed {
961 Ok(Auth::Accept)
962 } else {
963 Ok(Auth::Reject {
964 proceed_with_methods: None,
965 })
966 }
967 }
968
969 async fn channel_open_session(
970 &mut self,
971 channel: Channel<Msg>,
972 _session: &mut Session,
973 ) -> Result<bool, Self::Error> {
974 if !self.config.shell_enabled {
975 debug!("SSH shell channel requested but shell is disabled");
976 return Ok(false);
977 }
978
979 let channel_id = channel.id();
980 self.channel_types
981 .lock()
982 .await
983 .insert(channel_id, ChannelType::Session);
984 self.channels.lock().await.insert(channel_id, channel);
985
986 debug!("SSH session channel {} opened", channel_id);
987 Ok(true)
988 }
989
990 async fn subsystem_request(
991 &mut self,
992 channel_id: ChannelId,
993 name: &str,
994 session: &mut Session,
995 ) -> Result<(), Self::Error> {
996 // DEBUG: SSH subsystem request summary
997 Log::new(Some(&self.status_tx)).debug(format!(
998 "SSH request: SUBSYSTEM channel={}, name={}",
999 channel_id, name
1000 ));
1001
1002 // TRACE: Full SSH subsystem request
1003 Log::new(Some(&self.status_tx)).trace(format!(
1004 "SSH SUBSYSTEM request: channel={}, name='{}', connection={}",
1005 channel_id, name, self.connection_id
1006 ));
1007
1008 if name == "sftp" {
1009 if !self.config.sftp_enabled {
1010 // Client asked for a disabled subsystem; the server answers with
1011 // CHANNEL_FAILURE, so this is a WARN, not an error.
1012 Log::new(Some(&self.status_tx))
1013 .warn("SFTP subsystem requested but SFTP is disabled");
1014 Log::new(Some(&self.status_tx))
1015 .debug("SSH response: CHANNEL_FAILURE (SFTP disabled)");
1016
1017 session.channel_failure(channel_id);
1018 return Ok(());
1019 }
1020
1021 self.channel_types
1022 .lock()
1023 .await
1024 .insert(channel_id, ChannelType::Sftp);
1025
1026 // INFO: Major lifecycle event
1027 Log::new(Some(&self.status_tx)).info(format!(
1028 "SSH SFTP subsystem started on channel {} (connection {})",
1029 channel_id, self.connection_id
1030 ));
1031
1032 // Get the channel object
1033 if let Some(channel) = self.get_channel(channel_id).await {
1034 Log::new(Some(&self.status_tx))
1035 .debug("SSH response: CHANNEL_SUCCESS (starting SFTP handler)");
1036
1037 Log::new(Some(&self.status_tx)).trace(format!(
1038 "Creating LlmSftpHandler for channel {} on connection {}",
1039 channel_id, self.connection_id
1040 ));
1041
1042 session.channel_success(channel_id);
1043
1044 // Create LLM-controlled SFTP handler
1045 let server_id = self
1046 .server_id
1047 .unwrap_or_else(|| crate::state::ServerId::new(1));
1048 let sftp_handler = crate::server::LlmSftpHandler::new(
1049 self.connection_id,
1050 server_id,
1051 self.llm_client.clone(),
1052 self.app_state.clone(),
1053 self.protocol.clone(),
1054 self.status_tx.clone(),
1055 self.owner.clone(),
1056 );
1057
1058 // Run SFTP protocol (this handles all packet parsing)
1059 Log::new(Some(&self.status_tx)).trace(format!(
1060 "Starting russh_sftp::server::run() for channel {}",
1061 channel_id
1062 ));
1063
1064 russh_sftp::server::run(channel.into_stream(), sftp_handler).await;
1065
1066 // INFO: SFTP session ended (normal lifecycle end)
1067 Log::new(Some(&self.status_tx)).info(format!(
1068 "SFTP session ended on channel {} (connection {})",
1069 channel_id, self.connection_id
1070 ));
1071
1072 Log::new(Some(&self.status_tx)).debug(format!(
1073 "SSH: SFTP subsystem terminated on channel {}",
1074 channel_id
1075 ));
1076 } else {
1077 Log::new(Some(&self.status_tx))
1078 .error(format!("SFTP channel {} not found", channel_id));
1079 Log::new(Some(&self.status_tx))
1080 .debug("SSH response: CHANNEL_FAILURE (channel not found)");
1081
1082 session.channel_failure(channel_id);
1083 }
1084 } else {
1085 // Client asked for an unknown subsystem; the server rejects it with
1086 // CHANNEL_FAILURE, so this is a WARN, not an error.
1087 Log::new(Some(&self.status_tx)).warn(format!(
1088 "Unknown subsystem requested: '{}' on channel {}",
1089 name, channel_id
1090 ));
1091 Log::new(Some(&self.status_tx)).debug(format!(
1092 "SSH response: CHANNEL_FAILURE (unknown subsystem '{}')",
1093 name
1094 ));
1095 Log::new(Some(&self.status_tx)).trace(format!(
1096 "SSH rejecting unknown subsystem: name='{}', channel={}, connection={}",
1097 name, channel_id, self.connection_id
1098 ));
1099
1100 session.channel_failure(channel_id);
1101 }
1102
1103 Ok(())
1104 }
1105
1106 async fn shell_request(
1107 &mut self,
1108 channel_id: ChannelId,
1109 session: &mut Session,
1110 ) -> Result<(), Self::Error> {
1111 debug!("SSH shell request on channel {}", channel_id);
1112
1113 if !self.config.shell_enabled {
1114 error!("Shell requested but shell is disabled");
1115 session.channel_failure(channel_id);
1116 return Ok(());
1117 }
1118
1119 session.channel_success(channel_id);
1120
1121 // Send banner/greeting via LLM
1122 if let Ok(Some(banner)) = self.llm_shell_banner().await {
1123 let data = CryptoVec::from_slice(banner.as_bytes());
1124 session.data(channel_id, data);
1125 debug!("Sent shell banner ({} bytes)", banner.len());
1126 }
1127
1128 Ok(())
1129 }
1130
1131 async fn exec_request(
1132 &mut self,
1133 channel_id: ChannelId,
1134 data: &[u8],
1135 session: &mut Session,
1136 ) -> Result<(), Self::Error> {
1137 let command = String::from_utf8_lossy(data);
1138 debug!("SSH exec request on channel {}: {:?}", channel_id, command);
1139
1140 if !self.config.shell_enabled {
1141 error!("Exec requested but shell is disabled");
1142 session.channel_failure(channel_id);
1143 return Ok(());
1144 }
1145
1146 session.channel_success(channel_id);
1147
1148 // Execute command via LLM. A one-shot `ssh host <cmd>` is always the first (and only)
1149 // input on this channel.
1150 //
1151 // The exit status is the whole answer for a one-shot exec: a script running
1152 // `ssh host cmd` branches on it and, in the common `$(ssh host cmd)` form, never even
1153 // sees stderr. So a backend failure has to exit non-zero. This used to send
1154 // `exit-status 0` with no output on every branch, which is indistinguishable from a
1155 // command that ran and printed nothing — the same defect the interactive shell path
1156 // already fixes by disconnecting.
1157 let exit_status: u32 = match self.llm_shell_command(data, true).await {
1158 Ok((output, _close)) => {
1159 if let Some(output_text) = output {
1160 let data = CryptoVec::from_slice(output_text.as_bytes());
1161 session.data(channel_id, data);
1162 debug!("Sent exec output ({} bytes)", output_text.len());
1163 } else {
1164 // The handler answered, but with nothing to print. That is a legitimate
1165 // "command succeeded silently", and it stays exit 0 — but it is tagged
1166 // separately from the backend-error case so the log can tell them apart.
1167 Log::new(Some(&self.status_tx)).debug(format!(
1168 "SSH exec on channel {}: decision=model_answer_empty",
1169 channel_id
1170 ));
1171 }
1172 0
1173 }
1174 Err(e) => {
1175 // `llm_shell_command` has already logged the error in full. Here the error is
1176 // used only to pick a category: `WireFailure::prefixed_text()` is a
1177 // `&'static str`, so nothing derived from `e` can reach the peer.
1178 let failure = crate::utils::WireFailure::classify(&e);
1179 Log::new(Some(&self.status_tx)).warn(format!(
1180 "SSH exec on channel {}: decision=fail_closed_backend_error category={:?}",
1181 channel_id, failure
1182 ));
1183
1184 // stderr, not stdout: `$(ssh host cmd)` captures stdout, and a notice mixed
1185 // into it would be indistinguishable from the command's own output.
1186 let notice =
1187 CryptoVec::from_slice(format!("{}\r\n", failure.prefixed_text()).as_bytes());
1188 session.extended_data(channel_id, 1, notice);
1189
1190 // Distinct statuses so a caller can back off instead of recording a permanent
1191 // fault: sysexits.h EX_TEMPFAIL (75) for a saturated backend, EX_UNAVAILABLE
1192 // (69) for anything else. SSH itself has no error code of its own here — the
1193 // exit status is the only field that can carry the distinction.
1194 if failure.is_overloaded() {
1195 75
1196 } else {
1197 69
1198 }
1199 }
1200 };
1201
1202 // Close channel after exec
1203 session.exit_status_request(channel_id, exit_status);
1204 session.eof(channel_id);
1205 self.close_channel_once(session, channel_id).await;
1206
1207 Ok(())
1208 }
1209
1210 async fn data(
1211 &mut self,
1212 channel_id: ChannelId,
1213 data: &[u8],
1214 session: &mut Session,
1215 ) -> Result<(), Self::Error> {
1216 let channel_types = self.channel_types.lock().await;
1217 let channel_type = channel_types.get(&channel_id).cloned();
1218 drop(channel_types); // Release lock before async operations
1219
1220 match channel_type {
1221 Some(ChannelType::Session) => {
1222 // Shell data - handle backspace, echo properly, and buffer until newline or Ctrl-C
1223 Log::new(Some(&self.status_tx)).trace(format!(
1224 "SSH shell data received on channel {}: hex={:02x?}",
1225 channel_id, data
1226 ));
1227
1228 // Get or create buffer for this channel
1229 let mut buffers = self.shell_buffers.lock().await;
1230 let buffer = buffers.entry(channel_id).or_insert_with(Vec::new);
1231
1232 // Bounded before anything is appended. This buffer is flushed to the model
1233 // only when `should_process` sees a newline or a control byte, so a peer that
1234 // opens a shell channel and sends `'A'` forever grows it across an unlimited
1235 // number of SSH packets — one `Vec` per open channel, and russh replenishes
1236 // the channel window for free. russh's own 256 KiB transport-packet limit and
1237 // its 32 KiB `maximum_packet_size` bound each *packet* and give no protection
1238 // against accumulation across packets.
1239 //
1240 // The line is dropped rather than the session, because an over-long line on an
1241 // interactive shell is far more often a paste accident than an attack, and a
1242 // newline is a natural resynchronisation point — unlike the line-oriented text
1243 // protocols, where the peer is mid-frame and the connection must close. SSH
1244 // has no size-refusal code, so the notice goes on the channel where a user
1245 // will see it.
1246 if buffer.len().saturating_add(data.len()) > MAX_SHELL_LINE_BYTES {
1247 buffer.clear();
1248 drop(buffers);
1249 tracing::error!(
1250 "SSH shell line on channel {} exceeded {} bytes; discarded. \
1251 decision=fail_closed_oversized_line",
1252 channel_id,
1253 MAX_SHELL_LINE_BYTES
1254 );
1255 Log::new(Some(&self.status_tx)).error(format!(
1256 "SSH channel {} sent a shell line over {} bytes; discarded",
1257 channel_id, MAX_SHELL_LINE_BYTES
1258 ));
1259 let notice =
1260 CryptoVec::from_slice(b"\r\n[netget] input line too long, discarded\r\n");
1261 session.data(channel_id, notice);
1262 return Ok(());
1263 }
1264
1265 // Process each byte
1266 for &byte in data {
1267 match byte {
1268 // Backspace (0x7F) or Delete (0x08)
1269 0x7F | 0x08 => {
1270 if !buffer.is_empty() {
1271 buffer.pop();
1272 // Echo: backspace + space + backspace (to erase character on screen)
1273 let erase = CryptoVec::from_slice(&[0x08, b' ', 0x08]);
1274 session.data(channel_id, erase);
1275 trace!("SSH shell: backspace, buffer now {} bytes", buffer.len());
1276 }
1277 }
1278 // Tab (0x09) - echo but don't buffer (for tab completion)
1279 0x09 => {
1280 let echo = CryptoVec::from_slice(&[byte]);
1281 session.data(channel_id, echo);
1282 // Don't buffer tabs - they should be handled immediately by the client
1283 // or used for tab completion which doesn't need buffering
1284 }
1285 // Newline characters (Enter key)
1286 b'\n' | b'\r' => {
1287 // Echo newline as \r\n (proper line ending for terminals)
1288 let echo = CryptoVec::from_slice(b"\r\n");
1289 session.data(channel_id, echo);
1290 // Add actual received character to buffer
1291 buffer.push(byte);
1292 }
1293 // Control characters (0x01-0x1F except tab/newline/carriage return)
1294 0x01..=0x1F => {
1295 // Echo control characters visually as "^X\r\n" (e.g., ^C, ^D, ^Z)
1296 // Control character to printable: add 0x40 (e.g., 0x03 + 0x40 = 0x43 = 'C')
1297 let ctrl_char = byte + 0x40;
1298 let echo_str = format!("^{}\r\n", ctrl_char as char);
1299 let echo = CryptoVec::from_slice(echo_str.as_bytes());
1300 session.data(channel_id, echo);
1301 // Add to buffer for LLM to see
1302 buffer.push(byte);
1303 }
1304 // Printable characters (0x20-0x7E)
1305 0x20..=0x7E => {
1306 // Echo the character
1307 let echo = CryptoVec::from_slice(&[byte]);
1308 session.data(channel_id, echo);
1309 // Add to buffer
1310 buffer.push(byte);
1311 }
1312 // Other bytes (non-printable, non-control)
1313 _ => {
1314 // Just add to buffer without echo
1315 buffer.push(byte);
1316 }
1317 }
1318 }
1319
1320 // Check if we should process the buffer (Enter or control characters received)
1321 // NOTE: Echo has already happened above in the byte loop - LLM invocation comes AFTER echo
1322 // Process on: Enter (\r, \n) or any control character except Tab (0x09)
1323 let should_process = data.iter().any(|&b| {
1324 b == b'\n' || b == b'\r' || ((0x01..=0x1F).contains(&b) && b != 0x09)
1325 });
1326
1327 if should_process {
1328 // Check if this is the first interaction (for banner) or empty input
1329 let mut initialized = self.channel_initialized.lock().await;
1330 let is_first_input = !initialized.get(&channel_id).copied().unwrap_or(false);
1331 initialized.insert(channel_id, true);
1332 drop(initialized);
1333
1334 let line = buffer.clone();
1335 buffer.clear();
1336 drop(buffers);
1337
1338 // Only call LLM if:
1339 // 1. First input (even if empty) - for banner
1340 // 2. Non-empty input (command to process)
1341 // 3. Any control character present (Ctrl-C, Ctrl-D, etc.) - always process
1342 // 4. Empty Enter - LLM should respond with prompt
1343 let has_ctrl_c = line.contains(&0x03);
1344 let has_any_ctrl = line
1345 .iter()
1346 .any(|&b| (0x01..=0x1F).contains(&b) && b != 0x09);
1347 let is_empty_cmd = line.iter().all(|&b| b == b'\n' || b == b'\r');
1348
1349 // Always process if we have any control character or non-empty command
1350 if is_first_input || !is_empty_cmd || has_any_ctrl {
1351 Log::new(Some(&self.status_tx)).debug(format!(
1352 "SSH shell processing input ({} bytes, first={}, empty={})",
1353 line.len(),
1354 is_first_input,
1355 is_empty_cmd
1356 ));
1357
1358 Log::new(Some(&self.status_tx))
1359 .trace(format!("SSH shell input (hex): {:02x?}", line));
1360
1361 Log::new(Some(&self.status_tx)).trace(format!(
1362 "SSH shell input (text): {:?}",
1363 String::from_utf8_lossy(&line)
1364 ));
1365
1366 // Log-only summary. The flags the handler actually branches on are
1367 // built inside llm_shell_command() and travel in the event itself.
1368 let context = format!(
1369 " [first_input={}, empty_input={}, ctrl_c={}]",
1370 is_first_input, is_empty_cmd, has_ctrl_c
1371 );
1372
1373 let command_result = self.llm_shell_command(&line, is_first_input).await;
1374
1375 // A failed handler call ends the session with a reason code rather
1376 // than leaving the peer at a prompt that will never answer.
1377 // SSH_DISCONNECT_SERVICE_NOT_AVAILABLE (7) is the closest reason
1378 // RFC 4253 §11.1 defines, and ssh(1) prints it verbatim.
1379 if let Err(ref e) = command_result {
1380 // `llm_shell_command` logged the error in full. Only the category
1381 // travels from here on: `prefixed_text()` is a `&'static str`, so
1382 // no backend URL, model name or anyhow chain can reach the
1383 // terminal of whoever is logged in.
1384 let failure = crate::utils::WireFailure::classify(e);
1385 let description = failure.prefixed_text();
1386 let notice = CryptoVec::from_slice(
1387 format!("\r\n{}\r\n", description).as_bytes(),
1388 );
1389 session.data(channel_id, notice);
1390 // sysexits.h: EX_TEMPFAIL (75) says "retry", EX_UNAVAILABLE (69)
1391 // says "do not". SSH's disconnect reason codes have no equivalent
1392 // pair, so the exit status carries the distinction — matching what
1393 // `exec_request` reports for the same failure.
1394 session.exit_status_request(
1395 channel_id,
1396 if failure.is_overloaded() { 75 } else { 69 },
1397 );
1398 session.eof(channel_id);
1399 self.close_channel_once(session, channel_id).await;
1400 session.disconnect(
1401 russh::Disconnect::ServiceNotAvailable,
1402 description,
1403 "en",
1404 );
1405 Log::new(Some(&self.status_tx)).warn(format!(
1406 "SSH disconnected channel {}: \
1407 decision=fail_closed_backend_error category={:?}",
1408 channel_id, failure
1409 ));
1410 }
1411
1412 if let Ok((output, close_connection)) = command_result {
1413 // Send output if present
1414 if let Some(output_text) = output {
1415 let response = CryptoVec::from_slice(output_text.as_bytes());
1416 session.data(channel_id, response);
1417
1418 Log::new(Some(&self.status_tx)).debug(format!(
1419 "Sent shell response ({} bytes){}",
1420 output_text.len(),
1421 context
1422 ));
1423 }
1424
1425 // Handle close_connection flag (e.g., from Ctrl-C)
1426 if close_connection {
1427 Log::new(Some(&self.status_tx)).info(format!(
1428 "LLM requested shell connection close on channel {}",
1429 channel_id
1430 ));
1431
1432 session.exit_status_request(channel_id, 0);
1433 session.eof(channel_id);
1434 self.close_channel_once(session, channel_id).await;
1435 } else {
1436 // Send a prompt after the response so user knows where to type next
1437 // This prevents commands from being echoed on the same line as output
1438 let prompt = CryptoVec::from_slice(b"$ ");
1439 session.data(channel_id, prompt);
1440 }
1441 }
1442 } else {
1443 // Empty Enter press after initialization - ignore it
1444 Log::new(Some(&self.status_tx))
1445 .trace("SSH shell: ignoring empty Enter (already initialized)");
1446 }
1447 } else {
1448 // Still accumulating input
1449 Log::new(Some(&self.status_tx))
1450 .trace(format!("SSH shell buffering: {} bytes total", buffer.len()));
1451 }
1452 }
1453 Some(ChannelType::Sftp) => {
1454 // SFTP data is handled by russh_sftp::server::run() in subsystem_request()
1455 // This case shouldn't normally be reached
1456 debug!(
1457 "SFTP data received on channel {} - should be handled by SFTP subsystem",
1458 channel_id
1459 );
1460 }
1461 None => {
1462 debug!("Data received on unknown channel {}", channel_id);
1463 }
1464 }
1465
1466 Ok(())
1467 }
1468
1469 async fn channel_eof(
1470 &mut self,
1471 channel_id: ChannelId,
1472 session: &mut Session,
1473 ) -> Result<(), Self::Error> {
1474 debug!("SSH channel {} EOF", channel_id);
1475 // The peer will send no more data. Closing here is what ends a shell channel whose
1476 // client closed stdin — but on an exec channel the answer has usually already been
1477 // written and closed, so this must not be a second CLOSE. See `close_channel_once`.
1478 self.close_channel_once(session, channel_id).await;
1479 Ok(())
1480 }
1481
1482 async fn channel_close(
1483 &mut self,
1484 channel_id: ChannelId,
1485 _session: &mut Session,
1486 ) -> Result<(), Self::Error> {
1487 debug!("SSH channel {} closed", channel_id);
1488 self.channel_types.lock().await.remove(&channel_id);
1489 self.channels.lock().await.remove(&channel_id);
1490 self.shell_buffers.lock().await.remove(&channel_id);
1491 self.channel_initialized.lock().await.remove(&channel_id);
1492 // The peer has closed too, so this channel number is finished with and may be reused
1493 // by a later channel on the same connection. Forgetting it here keeps the
1494 // close-once guard scoped to one channel rather than to one connection.
1495 self.channels_closed.lock().await.remove(&channel_id);
1496 Ok(())
1497 }
1498}