Skip to main content

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}