1
//! russh `Handler` + `Server` glue.
2
//!
3
//! Accepts `publickey` and `password` (rate-limited) auth, accepts
4
//! `pty-req` + `shell`, and refuses everything else the threat model
5
//! excludes (`exec`, `subsystem`, `direct-tcpip`, `x11-req`,
6
//! `auth-agent-req`).
7
//!
8
//! The `shell_request` launch site runs the live TUI: it builds an
9
//! `SshTransport`, drives `tui::run_loop`, and attaches a per-session
10
//! DB-backed `ConsoleEval` so the Console tab evaluates nomiscript
11
//! against the authenticated user's `rpc::Session`.
12

            
13
use crate::auth::{Outcome, authenticate_password, authenticate_publickey};
14
use crate::eval_channel::{EvalChannelState, RusshSink};
15
use crate::key_decoder::KeyDecoder;
16
use crate::rate_limit::{InstantClock, RateLimiter};
17
use crate::tui_transport::SshTransport;
18
use cli_core::ssh_keys::parse_authorized_keys_line;
19
use rpc::ScriptCtx;
20
use russh::keys::{HashAlg, PublicKey};
21
use russh::server::{Auth, ChannelOpenHandle, Handler, Server, Session};
22
use russh::{Channel, ChannelId, ChannelOpenFailure, Pty};
23
use server::command::CmdResult;
24
use server::command::ssh_key::AddSshKey;
25
use server::command::user::Refusal;
26
use sqlx::types::Uuid;
27
use std::collections::HashMap;
28
use std::net::SocketAddr;
29
use std::sync::Arc;
30
use tokio::sync::Mutex;
31
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
32
use tui::transport::{RawEvent, Transport};
33
use tui::widgets::EditMode;
34

            
35
/// Banner template used after a successful ssh-copy-id upload. The
36
/// daemon substitutes `{hostname}`, `{port}`, `{user}`, `{fingerprint}`
37
/// at runtime.
38
#[derive(Clone)]
39
pub struct SshConnectInfo {
40
    pub hostname: String,
41
    pub port: u16,
42
    pub host_fingerprint: String,
43
}
44

            
45
/// Authentication method used by the active session. Phase C only
46
/// permits the `exec` channel that ssh-copy-id opens when the
47
/// caller authenticated via password.
48
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49
pub enum AuthMethod {
50
    Password,
51
    Publickey,
52
}
53

            
54
/// Shared state across every accepted session.
55
pub struct SshdState {
56
    pub limiter: Arc<Mutex<RateLimiter<InstantClock>>>,
57
    pub connect_info: SshConnectInfo,
58
}
59

            
60
impl Server for SshdState {
61
    type Handler = Session_;
62

            
63
1
    fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
64
1
        Session_ {
65
1
            peer: peer_addr,
66
1
            limiter: Arc::clone(&self.limiter),
67
1
            connect_info: self.connect_info.clone(),
68
1
            user_id: None,
69
1
            user_name: None,
70
1
            auth_method: None,
71
1
            pubkey_uploads: HashMap::new(),
72
1
            tui_channels: HashMap::new(),
73
1
            eval_channels: HashMap::new(),
74
1
        }
75
1
    }
76

            
77
    fn handle_session_error(&mut self, error: <Self::Handler as Handler>::Error) {
78
        // The Kubernetes TCP probes connect and hang up every few seconds, and a
79
        // client that walks away looks the same; logging those at warn buried
80
        // real session failures under dozens of lines an hour.
81
        if is_peer_hangup(&error) {
82
            log::debug!("session ended by peer: {error:?}");
83
        } else {
84
            log::warn!("session error: {error:?}");
85
        }
86
    }
87
}
88

            
89
/// Per-channel state for an active TUI session — the keystroke
90
/// decoder, the input mpsc sender, and the PTY geometry the client
91
/// announced. Created in `pty_request`, completed in `shell_request`,
92
/// and dropped in `channel_close`.
93
struct TuiChannelState {
94
    decoder: KeyDecoder,
95
    input_tx: Option<UnboundedSender<RawEvent>>,
96
    term: Option<String>,
97
    cols: u16,
98
    rows: u16,
99
}
100

            
101
/// Per-connection handler. Odd name because `Session` is taken by
102
/// russh.
103
pub struct Session_ {
104
    peer: Option<SocketAddr>,
105
    limiter: Arc<Mutex<RateLimiter<InstantClock>>>,
106
    connect_info: SshConnectInfo,
107
    user_id: Option<Uuid>,
108
    user_name: Option<String>,
109
    auth_method: Option<AuthMethod>,
110
    /// Buffers for `exec` channels that ssh-copy-id is feeding pubkey
111
    /// lines through. Drained on `channel_eof`.
112
    pubkey_uploads: HashMap<ChannelId, Vec<u8>>,
113
    /// Per-channel TUI bridge state. Disjoint from `pubkey_uploads`:
114
    /// a channel is either an ssh-copy-id upload or a TUI session,
115
    /// never both.
116
    tui_channels: HashMap<ChannelId, TuiChannelState>,
117
    /// Per-channel state for the `nomisync-eval` subsystem. Disjoint
118
    /// from `pubkey_uploads` and `tui_channels`: a channel commits
119
    /// to one channel-type on its first request. Created in
120
    /// `subsystem_request`, drained in `data`, dropped in
121
    /// `channel_eof` / `channel_close`.
122
    eval_channels: HashMap<ChannelId, EvalChannelState>,
123
}
124

            
125
impl Session_ {
126
1
    fn peer_ip(&self) -> std::net::IpAddr {
127
1
        self.peer
128
1
            .map_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |s| {
129
1
                s.ip()
130
1
            })
131
1
    }
132

            
133
    /// Refuses a channel whose tenant database is withheld by the fleet
134
    /// migrator, telling the user why rather than failing opaquely inside the
135
    /// session.
136
    ///
137
    /// Fails **closed**: a directory lookup that errors refuses too. Treating an
138
    /// unreadable directory as "not quarantined" would hand a session to
139
    /// exactly the tenant whose state could not be confirmed.
140
    ///
141
    /// Every channel that can reach the tenant's database goes through here.
142
    /// Guarding only the shell left `nomisync-eval` open, and that subsystem
143
    /// evaluates scripts against the same database.
144
1
    async fn refuse_if_withheld(
145
1
        &self,
146
1
        user_id: Uuid,
147
1
        channel: ChannelId,
148
1
        session: &mut Session,
149
1
        kind: &str,
150
1
    ) -> Result<bool, russh::Error> {
151
1
        let refusal = match server::command::user::account_refusal(user_id).await {
152
1
            Ok(refusal) => refusal,
153
            Err(e) => {
154
                log::error!("{kind}_request refusing user_id={user_id}: {e}");
155
                Some(Refusal::Quarantined)
156
            }
157
        };
158

            
159
1
        let Some(refusal) = refusal else {
160
1
            return Ok(false);
161
        };
162

            
163
        let notice = match refusal {
164
            Refusal::Quarantined => {
165
                log::warn!("{kind}_request refused user_id={user_id} reason=quarantined");
166
                "\r\nThis account is undergoing maintenance and is temporarily \
167
                 unavailable. Please try again shortly.\r\n"
168
            }
169
            Refusal::Disabled => {
170
                log::warn!("{kind}_request refused user_id={user_id} reason=disabled");
171
                "\r\nThis account is disabled.\r\n"
172
            }
173
        }
174
        .to_owned();
175
        session.data(channel, notice.into_bytes())?;
176
        session.close(channel)?;
177
        Ok(true)
178
1
    }
179

            
180
    fn refuse_request(&self, kind: &str) {
181
        log::warn!(
182
            "refused request kind={kind} peer={:?} user_id={:?}",
183
            self.peer,
184
            self.user_id
185
        );
186
    }
187

            
188
    /// True only for the ssh-copy-id bootstrap window: the session
189
    /// authenticated with a password AND the user does not yet have
190
    /// any registered key.
191
    async fn is_bootstrap_upload_allowed(&self) -> bool {
192
        if self.auth_method != Some(AuthMethod::Password) {
193
            return false;
194
        }
195
        let Some(uid) = self.user_id else {
196
            return false;
197
        };
198
        matches!(
199
            server::command::ssh_key::UserHasSshKey::new()
200
                .user_id(uid)
201
                .run()
202
                .await,
203
            Ok(Some(CmdResult::Bool(false)))
204
        )
205
    }
206

            
207
    /// Parse the bytes ssh-copy-id wrote to stdin (one or more
208
    /// `authorized_keys` lines) and register each line via
209
    /// [`AddSshKey`]. Returns the operator-facing banner that the
210
    /// channel writes back before closing.
211
    async fn process_pubkey_upload(&self, buf: &[u8]) -> String {
212
        let Some(uid) = self.user_id else {
213
            return "ssh-copy-id: no authenticated user; aborting.\n".to_string();
214
        };
215
        let raw = match std::str::from_utf8(buf) {
216
            Ok(s) => s,
217
            Err(_) => {
218
                return "ssh-copy-id: pubkey payload is not UTF-8.\n".to_string();
219
            }
220
        };
221

            
222
        let mut accepted = 0usize;
223
        let mut errors: Vec<String> = Vec::new();
224
        for line in raw.lines() {
225
            let trimmed = line.trim();
226
            if trimmed.is_empty() || trimmed.starts_with('#') {
227
                continue;
228
            }
229
            let parsed = match parse_authorized_keys_line(trimmed) {
230
                Ok(p) => p,
231
                Err(e) => {
232
                    errors.push(format!("parse: {e}"));
233
                    continue;
234
                }
235
            };
236
            let annotation = if parsed.comment.is_empty() {
237
                "ssh-copy-id".to_string()
238
            } else {
239
                format!("ssh-copy-id ({})", parsed.comment)
240
            };
241
            match AddSshKey::new()
242
                .user_id(uid)
243
                .key_type(parsed.key_type)
244
                .key_blob(parsed.key_blob)
245
                .fingerprint(parsed.fingerprint)
246
                .annotation(annotation)
247
                .run()
248
                .await
249
            {
250
                Ok(Some(CmdResult::Uuid(_))) => accepted += 1,
251
                Ok(_) => errors.push("server returned no key id".to_string()),
252
                Err(e) => errors.push(e.to_string()),
253
            }
254
        }
255

            
256
        if accepted == 0 {
257
            let detail = if errors.is_empty() {
258
                "no usable pubkey lines".to_string()
259
            } else {
260
                errors.join("; ")
261
            };
262
            return format!("ssh-copy-id: no key accepted ({detail}).\n");
263
        }
264

            
265
        let user_label = self.user_name.as_deref().unwrap_or("YOUR_LOGIN");
266
        format!(
267
            "Identity added; password auth disabled.\r\n\
268
             \r\n\
269
             Suggested ~/.ssh/config entry:\r\n\
270
             \r\n\
271
             Host nomisync\r\n\
272
             \x20   HostName       {hostname}\r\n\
273
             \x20   Port           {port}\r\n\
274
             \x20   User           {user}\r\n\
275
             \x20   IdentitiesOnly yes\r\n\
276
             \x20   # IdentityAgent ${{SSH_AUTH_SOCK}}    # uncomment if using gpg-agent\r\n\
277
             \x20   # IdentityFile  ~/.ssh/id_ed25519     # or point at your private key\r\n\
278
             \r\n\
279
             Host key fingerprint (verify on first connect):\r\n\
280
             \x20   {fp}\r\n",
281
            hostname = self.connect_info.hostname,
282
            port = self.connect_info.port,
283
            user = user_label,
284
            fp = self.connect_info.host_fingerprint,
285
        )
286
    }
287
}
288

            
289
impl Handler for Session_ {
290
    type Error = russh::Error;
291

            
292
1
    async fn auth_publickey(
293
1
        &mut self,
294
1
        user: &str,
295
1
        public_key: &PublicKey,
296
1
    ) -> Result<Auth, Self::Error> {
297
1
        let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
298
1
        let outcome = authenticate_publickey(user, &fingerprint, self.peer_ip(), &self.limiter)
299
1
            .await
300
1
            .map_err(|_| russh::Error::Disconnect)?;
301
1
        match outcome {
302
1
            Outcome::Accept(uid) => {
303
1
                self.user_id = Some(uid);
304
1
                self.user_name = Some(user.to_string());
305
1
                self.auth_method = Some(AuthMethod::Publickey);
306
1
                Ok(Auth::Accept)
307
            }
308
            Outcome::Reject => Ok(Auth::reject()),
309
        }
310
1
    }
311

            
312
    async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
313
        let outcome = authenticate_password(user, password, self.peer_ip(), &self.limiter)
314
            .await
315
            .map_err(|_| russh::Error::Disconnect)?;
316
        match outcome {
317
            Outcome::Accept(uid) => {
318
                self.user_id = Some(uid);
319
                self.user_name = Some(user.to_string());
320
                self.auth_method = Some(AuthMethod::Password);
321
                Ok(Auth::Accept)
322
            }
323
            Outcome::Reject => Ok(Auth::reject()),
324
        }
325
    }
326

            
327
1
    async fn channel_open_session(
328
1
        &mut self,
329
1
        _channel: Channel<russh::server::Msg>,
330
1
        reply: ChannelOpenHandle,
331
1
        _session: &mut Session,
332
1
    ) -> Result<(), Self::Error> {
333
1
        reply.accept().await;
334
1
        Ok(())
335
1
    }
336

            
337
    async fn pty_request(
338
        &mut self,
339
        channel: ChannelId,
340
        term: &str,
341
        col_width: u32,
342
        row_height: u32,
343
        _pix_width: u32,
344
        _pix_height: u32,
345
        _modes: &[(Pty, u32)],
346
        _session: &mut Session,
347
    ) -> Result<(), Self::Error> {
348
        let cols = u16::try_from(col_width).unwrap_or(80);
349
        let rows = u16::try_from(row_height).unwrap_or(24);
350
        self.tui_channels.insert(
351
            channel,
352
            TuiChannelState {
353
                decoder: KeyDecoder::new(),
354
                input_tx: None,
355
                term: Some(term.to_string()),
356
                cols,
357
                rows,
358
            },
359
        );
360
        Ok(())
361
    }
362

            
363
    async fn shell_request(
364
        &mut self,
365
        channel: ChannelId,
366
        session: &mut Session,
367
    ) -> Result<(), Self::Error> {
368
        let Some(uid) = self.user_id else {
369
            log::warn!("shell_request without authenticated user; rejecting");
370
            session.close(channel)?;
371
            return Err(russh::Error::Disconnect);
372
        };
373
        if self
374
            .refuse_if_withheld(uid, channel, session, "shell")
375
            .await?
376
        {
377
            return Ok(());
378
        }
379

            
380
        let Some(state) = self.tui_channels.get_mut(&channel) else {
381
            log::warn!("shell_request without prior pty-req on channel {channel:?}; rejecting");
382
            session.close(channel)?;
383
            return Err(russh::Error::Disconnect);
384
        };
385

            
386
        log::info!(
387
            "shell_request accepted user_id={uid} peer={:?} term={:?} cols={} rows={}",
388
            self.peer,
389
            state.term,
390
            state.cols,
391
            state.rows,
392
        );
393

            
394
        let (input_tx, input_rx) = unbounded_channel::<RawEvent>();
395
        state.input_tx = Some(input_tx);
396
        let term = state.term.clone();
397
        let cols = state.cols;
398
        let rows = state.rows;
399

            
400
        let handle = session.handle();
401
        let runtime = tokio::runtime::Handle::current();
402
        let runtime_for_spawn = runtime.clone();
403
        let runtime_for_console = runtime.clone();
404

            
405
        tokio::task::spawn_blocking(move || {
406
            match SshTransport::new(
407
                handle,
408
                channel,
409
                runtime_for_spawn,
410
                input_rx,
411
                term,
412
                cols,
413
                rows,
414
            ) {
415
                Ok(mut transport) => {
416
                    let mut app = tui::App::new(uid, EditMode::Emacs);
417
                    match rpc::Session::guarded(rpc::ScriptCtx::new(uid))
418
                        .map(|s| tui::ConsoleEval::spawn_session(&runtime_for_console, s))
419
                    {
420
                        Ok(eval) => app.attach_console(eval),
421
                        Err(err) => {
422
                            log::warn!("console eval unavailable for user_id={uid}: {err}");
423
                            app.set_status(format!("console unavailable: {err}"));
424
                        }
425
                    }
426
                    if let Err(err) = tui::run_loop(&mut transport, &mut app) {
427
                        log::info!("tui session ended: {err}");
428
                    }
429
                    if let Err(err) = transport.finish() {
430
                        log::warn!("tui transport finish failed: {err}");
431
                    }
432
                }
433
                Err(err) => {
434
                    log::error!("tui transport build failed: {err}");
435
                }
436
            }
437
        });
438

            
439
        Ok(())
440
    }
441

            
442
    async fn window_change_request(
443
        &mut self,
444
        channel: ChannelId,
445
        col_width: u32,
446
        row_height: u32,
447
        _pix_width: u32,
448
        _pix_height: u32,
449
        _session: &mut Session,
450
    ) -> Result<(), Self::Error> {
451
        if let Some(state) = self.tui_channels.get_mut(&channel) {
452
            let cols = u16::try_from(col_width).unwrap_or(state.cols);
453
            let rows = u16::try_from(row_height).unwrap_or(state.rows);
454
            state.cols = cols;
455
            state.rows = rows;
456
            if let Some(tx) = &state.input_tx {
457
                let _ = tx.send(RawEvent::Resize(cols, rows));
458
            }
459
        }
460
        Ok(())
461
    }
462

            
463
    async fn exec_request(
464
        &mut self,
465
        channel: ChannelId,
466
        _data: &[u8],
467
        _session: &mut Session,
468
    ) -> Result<(), Self::Error> {
469
        // The only `exec` we accept is the one ssh-copy-id opens to
470
        // append to authorized_keys. Gate is "session authenticated
471
        // via password AND user has zero keys on file" — which is
472
        // exactly the bootstrap window. The requested command string
473
        // is ignored: stdin is consumed verbatim as authorized_keys
474
        // content, deferred to channel_eof.
475
        if !self.is_bootstrap_upload_allowed().await {
476
            self.refuse_request("exec");
477
            return Err(russh::Error::Disconnect);
478
        }
479
        log::info!(
480
            "exec_request accepted as ssh-copy-id upload user_id={:?} peer={:?}",
481
            self.user_id,
482
            self.peer
483
        );
484
        self.pubkey_uploads.entry(channel).or_default();
485
        Ok(())
486
    }
487

            
488
1
    async fn data(
489
1
        &mut self,
490
1
        channel: ChannelId,
491
1
        data: &[u8],
492
1
        session: &mut Session,
493
1
    ) -> Result<(), Self::Error> {
494
1
        if let Some(buf) = self.pubkey_uploads.get_mut(&channel) {
495
            buf.extend_from_slice(data);
496
            return Ok(());
497
1
        }
498
1
        if let Some(state) = self.tui_channels.get_mut(&channel) {
499
            let events = state.decoder.feed(data);
500
            if let Some(tx) = &state.input_tx {
501
                for ev in events {
502
                    if tx.send(ev).is_err() {
503
                        // run_loop has exited — drop subsequent input
504
                        // until channel_close cleans up.
505
                        break;
506
                    }
507
                }
508
            }
509
            return Ok(());
510
1
        }
511
1
        if let Some(state) = self.eval_channels.get_mut(&channel)
512
1
            && let Err(err) = state.feed(data)
513
        {
514
            // Non-UTF8 on an s-expr channel — surface as a parse
515
            // error envelope and drop the connection.
516
            let msg = format!("(:id 0 :error (:code parse :message \"{err}\"))\n");
517
            session.data(channel, msg.into_bytes())?;
518
            session.close(channel)?;
519
            self.eval_channels.remove(&channel);
520
            return Ok(());
521
1
        }
522
        // Responses are delivered out-of-band by the channel's
523
        // worker via the russh server Handle (see `RusshSink`).
524
        // data() returns immediately so future bytes — including
525
        // ETX (C-g) — get processed without waiting on the
526
        // current eval.
527
1
        Ok(())
528
1
    }
529

            
530
1
    async fn channel_eof(
531
1
        &mut self,
532
1
        channel: ChannelId,
533
1
        session: &mut Session,
534
1
    ) -> Result<(), Self::Error> {
535
1
        if let Some(buf) = self.pubkey_uploads.remove(&channel) {
536
            let banner = self.process_pubkey_upload(&buf).await;
537
            session.data(channel, banner.into_bytes())?;
538
            session.close(channel)?;
539
1
        } else {
540
1
            self.tui_channels.remove(&channel);
541
1
            self.eval_channels.remove(&channel);
542
1
        }
543
1
        Ok(())
544
1
    }
545

            
546
    async fn channel_close(
547
        &mut self,
548
        channel: ChannelId,
549
        _session: &mut Session,
550
    ) -> Result<(), Self::Error> {
551
        // Either path is harmless if the entry is already gone.
552
        self.pubkey_uploads.remove(&channel);
553
        self.tui_channels.remove(&channel);
554
        self.eval_channels.remove(&channel);
555
        Ok(())
556
    }
557

            
558
1
    async fn subsystem_request(
559
1
        &mut self,
560
1
        channel: ChannelId,
561
1
        name: &str,
562
1
        session: &mut Session,
563
1
    ) -> Result<(), Self::Error> {
564
1
        if name != "nomisync-eval" {
565
            self.refuse_request("subsystem");
566
            return Err(russh::Error::Disconnect);
567
1
        }
568
1
        let Some(user_id) = self.user_id else {
569
            log::warn!("nomisync-eval requested before authentication; refusing");
570
            self.refuse_request("subsystem");
571
            return Err(russh::Error::Disconnect);
572
        };
573
        // The eval subsystem runs scripts against the tenant's own database, so
574
        // it is exactly as unsafe as a shell while that database is withheld.
575
1
        if self
576
1
            .refuse_if_withheld(user_id, channel, session, "subsystem")
577
1
            .await?
578
        {
579
            return Ok(());
580
1
        }
581

            
582
1
        let sink = Box::new(RusshSink::new(session.handle(), channel));
583
1
        match rpc::Session::guarded(ScriptCtx::new(user_id))
584
1
            .map(|session| EvalChannelState::with_session(session, sink))
585
        {
586
1
            Ok(state) => {
587
1
                self.eval_channels.insert(channel, state);
588
1
                Ok(())
589
            }
590
            Err(err) => {
591
                log::error!("nomisync-eval session init failed: {err}");
592
                Err(russh::Error::Disconnect)
593
            }
594
        }
595
1
    }
596

            
597
    async fn channel_open_direct_tcpip(
598
        &mut self,
599
        _channel: Channel<russh::server::Msg>,
600
        _host_to_connect: &str,
601
        _port_to_connect: u32,
602
        _originator_address: &str,
603
        _originator_port: u32,
604
        reply: ChannelOpenHandle,
605
        _session: &mut Session,
606
    ) -> Result<(), Self::Error> {
607
        self.refuse_request("direct-tcpip");
608
        reply
609
            .reject(ChannelOpenFailure::AdministrativelyProhibited)
610
            .await;
611
        Ok(())
612
    }
613

            
614
    async fn tcpip_forward(
615
        &mut self,
616
        _address: &str,
617
        _port: &mut u32,
618
        _session: &mut Session,
619
    ) -> Result<bool, Self::Error> {
620
        self.refuse_request("tcpip-forward");
621
        Ok(false)
622
    }
623
}
624

            
625
/// Whether a session error only means the peer went away.
626
7
fn is_peer_hangup(error: &russh::Error) -> bool {
627
7
    match error {
628
2
        russh::Error::Disconnect | russh::Error::HUP => true,
629
4
        russh::Error::IO(e) => matches!(
630
4
            e.kind(),
631
            std::io::ErrorKind::ConnectionReset
632
                | std::io::ErrorKind::UnexpectedEof
633
                | std::io::ErrorKind::BrokenPipe
634
        ),
635
1
        _ => false,
636
    }
637
7
}
638

            
639
#[cfg(test)]
640
mod tests {
641
    use super::is_peer_hangup;
642
    use std::io::{Error, ErrorKind};
643

            
644
    #[test]
645
1
    fn a_peer_walking_away_is_not_a_session_error() {
646
5
        for hangup in [
647
1
            russh::Error::Disconnect,
648
1
            russh::Error::HUP,
649
1
            russh::Error::IO(Error::from(ErrorKind::ConnectionReset)),
650
1
            russh::Error::IO(Error::from(ErrorKind::UnexpectedEof)),
651
1
            russh::Error::IO(Error::from(ErrorKind::BrokenPipe)),
652
1
        ] {
653
5
            assert!(is_peer_hangup(&hangup), "{hangup:?}");
654
        }
655
1
    }
656

            
657
    #[test]
658
1
    fn a_real_failure_is_still_reported() {
659
2
        for failure in [
660
1
            russh::Error::InactivityTimeout,
661
1
            russh::Error::IO(Error::from(ErrorKind::PermissionDenied)),
662
1
        ] {
663
2
            assert!(!is_peer_hangup(&failure), "{failure:?}");
664
        }
665
1
    }
666
}