diff --git a/async/src/server.rs b/async/src/server.rs index 73c6b665..ff21163a 100644 --- a/async/src/server.rs +++ b/async/src/server.rs @@ -38,6 +38,24 @@ impl<'a> SSHServer<'a> { self.sunset.run(rsock, wsock).await } + /// Send `SSH_MSG_DISCONNECT`. See [`Runner::disconnect()`]. + /// + /// Must not be called while holding a [`ProgressHolder`] (deadlock). + pub async fn disconnect( + &self, + reason: DisconnectReason, + desc: &str, + ) -> Result<()> { + self.sunset.with_runner(|r| r.disconnect(reason, desc)).await + } + + /// Send `SSH_MSG_USERAUTH_BANNER`. See [`Runner::auth_banner()`]. + /// + /// Must not be called while holding a [`ProgressHolder`] (deadlock). + pub async fn auth_banner(&self, msg: &str) -> Result<()> { + self.sunset.with_runner(|r| r.auth_banner(msg)).await + } + /// Returns an event from the SSH session. /// /// Note that on return `ProgressHolder` holds a mutex over the session, diff --git a/changelog.md b/changelog.md index d3506e3b..91436c44 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,23 @@ # Sunset Changelog +## Unreleased + +### Changed + +- `CliEvent` and `ServEvent` have a new `Disconnected` variant, so + exhaustive matches on them need updating. + +### Added + +- `Runner::disconnect()` and `SSHServer::disconnect()` send + `SSH_MSG_DISCONNECT`, with `DisconnectReason` codes from RFC4253 s11.1. + +- A received `SSH_MSG_DISCONNECT` is reported as a `Disconnected` event + carrying the peer's reason and description. + +- `Runner::auth_banner()` and `SSHServer::auth_banner()` send + `SSH_MSG_USERAUTH_BANNER` (RFC4252 s5.4). + ## 0.6.0 - 2026-08-02 ### Changed diff --git a/demo/common/src/server.rs b/demo/common/src/server.rs index d36b70cf..6fe865ab 100644 --- a/demo/common/src/server.rs +++ b/demo/common/src/server.rs @@ -127,6 +127,10 @@ impl DemoCommon { info!("Ignored request for subsystem '{}'", a.command()?); Ok(()) } + ServEvent::Disconnected(d) => { + info!("Client disconnected: {:?}", d.reason()); + Ok(()) + } ServEvent::Defunct | ServEvent::SessionShell(_) | ServEvent::SessionExec(_) => { diff --git a/src/conn.rs b/src/conn.rs index 677206d1..7aba1792 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -2,7 +2,7 @@ use self::{ cliauth::CliAuth, - event::Banner, + event::{Banner, Disconnected}, packets::{AuthMethod, UserauthRequest}, }; @@ -116,10 +116,6 @@ impl DispatchEvent { /// Returned state from `handle_payload()` or `progress()` for `Runner` to use. pub(crate) struct Dispatched { pub event: DispatchEvent, - - /// packet was Disconnect - // TODO replace with an event - pub disconnect: bool, } pub trait CliServ: Sized + Send + Default + core::fmt::Debug { @@ -278,8 +274,7 @@ impl Conn { if !self.is_kex_sending() { let event = self.channels.progress(s); if !event.is_none() { - // TODO better Dispatched constructor - return Ok(Dispatched { event, disconnect: false }); + return Ok(Dispatched { event }); } } @@ -479,9 +474,12 @@ impl Conn { log!(level, "SSH debug message from remote host: {}", p.message); } Packet::Disconnect(_p) => { - // We ignore p.reason. // SSH2_DISCONNECT_BY_APPLICATION is normal, sent by openssh client. - disp.disconnect = true; + disp.event = if self.is_server() { + DispatchEvent::ServEvent(ServEventId::Disconnected) + } else { + DispatchEvent::CliEvent(CliEventId::Disconnected) + }; } Packet::UserauthRequest(p) => { let Some(serv) = self.cliserv.try_mut_server() else { @@ -553,6 +551,17 @@ impl Conn { }; Ok(disp) } + + pub(crate) fn fetch_disconnect<'p>( + &self, + payload: &'p [u8], + ) -> Result> { + if let Packet::Disconnect(d) = self.packet(payload)? { + Ok(Disconnected(d)) + } else { + Error::bug() + } + } } impl Conn { diff --git a/src/event.rs b/src/event.rs index f1828ec0..d1fcfd86 100644 --- a/src/event.rs +++ b/src/event.rs @@ -47,6 +47,8 @@ pub enum CliEvent<'g, 'a> { SessionOpened(CliSessionOpener<'g, 'a>), /// Remote process exited SessionExit(CliSessionExit<'g>), + /// The peer sent a disconnect message, ending the connection. + Disconnected(Disconnected<'g>), // ChanRequest(ChanRequest<'g, 'a>), // Banner { banner: TextString<'a>, language: TextString<'a> }, @@ -73,6 +75,7 @@ impl Debug for CliEvent<'_, '_> { Self::SessionExit(_) => "SessionExit", Self::AgentSign(_) => "AgentSign", Self::Banner(_) => "Banner", + Self::Disconnected(_) => "Disconnected", Self::Defunct => "Defunct", Self::PollAgain => "PollAgain", }; @@ -163,6 +166,30 @@ impl CheckHostkey<'_, '_> { } } +/// The peer sent `SSH_MSG_DISCONNECT`; the connection is over. +pub struct Disconnected<'a>(pub(crate) packets::Disconnect<'a>); + +impl Disconnected<'_> { + /// The reason, or `None` for a code not defined by RFC4253. + pub fn reason(&self) -> Option { + DisconnectReason::from_code(self.0.reason) + } + + /// The raw reason code. + pub fn reason_code(&self) -> u32 { + self.0.reason + } + + /// The peer's description. Untrusted remote text. + pub fn desc(&self) -> Result<&str> { + self.0.desc.to_str() + } + + pub fn raw_desc(&self) -> TextString<'_> { + self.0.desc + } +} + pub struct Banner<'a>(pub(crate) packets::UserauthBanner<'a>); impl Banner<'_> { @@ -188,10 +215,10 @@ pub(crate) enum CliEventId { SessionOpened(ChanNum), SessionExit, Banner, + Disconnected, #[expect(unused)] Defunct, // TODO: - // Disconnected // OpenTCPForwarded (new session) // TCPDirectOpened (response) } @@ -223,6 +250,9 @@ impl CliEventId { Ok(CliEvent::SessionExit(runner.fetch_cli_session_exit()?)) } Self::Banner => Ok(CliEvent::Banner(runner.fetch_cli_banner()?)), + Self::Disconnected => { + Ok(CliEvent::Disconnected(runner.fetch_disconnect()?)) + } Self::Defunct => error::BadUsage.fail(), } } @@ -238,6 +268,7 @@ impl CliEventId { | Self::SessionOpened(_) | Self::SessionExit | Self::Banner + | Self::Disconnected | Self::Defunct => false, Self::Hostkey | Self::Username @@ -301,6 +332,9 @@ pub enum ServEvent<'g, 'a> { /// Note: input strings are not sanitised. SessionEnv(ServEnvironmentRequest<'g, 'a>), + /// The peer sent a disconnect message, ending the connection. + Disconnected(Disconnected<'g>), + /// The SSH session is no longer running Defunct, @@ -326,6 +360,7 @@ impl Debug for ServEvent<'_, '_> { Self::SessionSubsystem(_) => "SessionSubsystem", Self::SessionPty(_) => "SessionPty", Self::SessionEnv(_) => "Environment", + Self::Disconnected(_) => "Disconnected", Self::Defunct => "Defunct", Self::PollAgain => "PollAgain", }; @@ -945,13 +980,12 @@ pub(crate) enum ServEventId { Environment { num: ChanNum, }, + Disconnected, #[expect(unused)] Defunct, // TODO: - // Disconnected // OpenTCPForwarded (new session) // TCPDirectOpened (response) - // Banner } impl ServEventId { @@ -1006,6 +1040,10 @@ impl ServEventId { debug_assert!(matches!(p, Some(Packet::ChannelRequest(_)))); Ok(ServEvent::SessionEnv(ServEnvironmentRequest::new(runner, num))) } + Self::Disconnected => { + debug_assert!(matches!(p, Some(Packet::Disconnect(_)))); + Ok(ServEvent::Disconnected(runner.fetch_disconnect()?)) + } Self::Defunct => Ok(ServEvent::Defunct), } } @@ -1014,7 +1052,7 @@ impl ServEventId { // Used for internal correctness checks. pub(crate) fn needs_resume(&self) -> bool { match self { - Self::Defunct | Self::Authenticated => false, + Self::Defunct | Self::Authenticated | Self::Disconnected => false, Self::Hostkeys | Self::FirstAuth | Self::PasswordAuth diff --git a/src/lib.rs b/src/lib.rs index 42865e3c..d49e070f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,7 +60,7 @@ pub use channel::{ChanOpened, Pty, SessionCommand}; pub use error::{Error, Result}; pub use packets::{PubKey, Signature}; pub use sign::{KeyType, OwnedSig, SignKey}; -pub use sshnames::ChanFail; +pub use sshnames::{ChanFail, DisconnectReason}; pub use event::{CliEvent, Event, ServEvent}; pub use runner::ChanHandle; diff --git a/src/packets.rs b/src/packets.rs index 328fa310..a916670b 100644 --- a/src/packets.rs +++ b/src/packets.rs @@ -1170,6 +1170,58 @@ mod tests { use crate::sshwire::{packet_from_bytes, write_ssh}; use crate::sunsetlog::init_test_log; + #[test] + fn disconnect_wire_format() { + init_test_log(); + let p = Packet::Disconnect(packets::Disconnect { + reason: DisconnectReason::SSH_DISCONNECT_BY_APPLICATION as u32, + desc: "bye".into(), + lang: "", + }); + let mut buf = vec![0u8; 64]; + let l = write_ssh(&mut buf, &p).unwrap(); + assert_eq!( + &buf[..l], + &[ + 1, // SSH_MSG_DISCONNECT + 0, 0, 0, 11, // SSH_DISCONNECT_BY_APPLICATION + 0, 0, 0, 3, b'b', b'y', b'e', // description + 0, 0, 0, 0, // empty language tag + ] + ); + test_roundtrip(&p); + } + + #[test] + fn disconnect_reason_from_code() { + for c in 1..=15 { + assert_eq!(DisconnectReason::from_code(c).map(|r| r as u32), Some(c)); + } + // Unknown codes are not a protocol error. + assert_eq!(DisconnectReason::from_code(0), None); + assert_eq!(DisconnectReason::from_code(16), None); + } + + #[test] + fn banner_wire_format() { + init_test_log(); + let p = Packet::UserauthBanner(packets::UserauthBanner { + message: "hi\r\n".into(), + lang: "".into(), + }); + let mut buf = vec![0u8; 64]; + let l = write_ssh(&mut buf, &p).unwrap(); + assert_eq!( + &buf[..l], + &[ + 53, // SSH_MSG_USERAUTH_BANNER + 0, 0, 0, 4, b'h', b'i', b'\r', b'\n', // message + 0, 0, 0, 0, // empty language tag + ] + ); + test_roundtrip(&p); + } + #[test] /// check round trip of packet enums is right fn packet_type() { diff --git a/src/runner.rs b/src/runner.rs index 8f5568ef..80cbb11b 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -191,6 +191,20 @@ impl<'a> Runner<'a, server::Server> { Self::new(inbuf, outbuf) } + /// Send `SSH_MSG_USERAUTH_BANNER`, a message shown before authentication + /// + /// Only meaningful before authentication succeeds + /// ([RFC4252](https://tools.ietf.org/html/rfc4252#section-5.4)). Clients + /// print `msg` verbatim, so it should normally end with CRLF. + pub fn auth_banner(&mut self, msg: &str) -> Result<()> { + debug!("auth_banner: {msg}"); + let p = packets::UserauthBanner { message: msg.into(), lang: "".into() }; + let mut s = self.traf_out.sender(&mut self.keys); + s.send(p)?; + self.wake(); + Ok(()) + } + pub(crate) fn resume_servhostkeys(&mut self, keys: &[&SignKey]) -> Result<()> { let (payload, _seq) = self.traf_in.payload().trap()?; let mut s = self.traf_out.sender(&mut self.keys); @@ -447,12 +461,40 @@ impl<'a, CS: CliServ> Runner<'a, CS> { set_waker(&mut self.input_waker, waker) } + /// The `Disconnect` packet currently being handled. + pub(crate) fn fetch_disconnect(&mut self) -> Result> { + let (payload, _seq) = self.traf_in.payload().trap()?; + self.conn.fetch_disconnect(payload) + } + /// Indicate that the input SSH tcp socket has closed pub fn close_input(&mut self) { trace!("close_input"); self.closed_input = true; } + /// Send `SSH_MSG_DISCONNECT`, telling the peer why the connection is ending + /// + /// `desc` is human-readable and may be shown to the user. The packet is + /// only queued: the caller must keep running until output has drained, + /// then close the connection. + pub fn disconnect( + &mut self, + reason: DisconnectReason, + desc: &str, + ) -> Result<()> { + debug!("disconnect {reason:?}: {desc}"); + let p = packets::Disconnect { + reason: reason as u32, + desc: desc.into(), + lang: "", + }; + let mut s = self.traf_out.sender(&mut self.keys); + s.send(p)?; + self.wake(); + Ok(()) + } + /// Write any pending output to the wire, returning the size written pub fn output(&mut self, buf: &mut [u8]) -> usize { let out = self.output_buf(); diff --git a/src/sshnames.rs b/src/sshnames.rs index d3691aa1..4dd004ab 100644 --- a/src/sshnames.rs +++ b/src/sshnames.rs @@ -81,6 +81,54 @@ pub enum ChanFail { SSH_OPEN_RESOURCE_SHORTAGE = 4, } +/// Reason codes for `SSH_MSG_DISCONNECT` +/// +/// [RFC4253](https://tools.ietf.org/html/rfc4253#section-11.1) +#[allow(non_camel_case_types)] +#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] +pub enum DisconnectReason { + SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT = 1, + SSH_DISCONNECT_PROTOCOL_ERROR = 2, + SSH_DISCONNECT_KEY_EXCHANGE_FAILED = 3, + SSH_DISCONNECT_RESERVED = 4, + SSH_DISCONNECT_MAC_ERROR = 5, + SSH_DISCONNECT_COMPRESSION_ERROR = 6, + SSH_DISCONNECT_SERVICE_NOT_AVAILABLE = 7, + SSH_DISCONNECT_PROTOCOL_VERSION_NOT_SUPPORTED = 8, + SSH_DISCONNECT_HOST_KEY_NOT_VERIFIABLE = 9, + SSH_DISCONNECT_CONNECTION_LOST = 10, + SSH_DISCONNECT_BY_APPLICATION = 11, + SSH_DISCONNECT_TOO_MANY_CONNECTIONS = 12, + SSH_DISCONNECT_AUTH_CANCELLED_BY_USER = 13, + SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE = 14, + SSH_DISCONNECT_ILLEGAL_USER_NAME = 15, +} + +impl DisconnectReason { + /// Returns the reason for a wire code, or `None` if it is not in RFC4253. + pub fn from_code(code: u32) -> Option { + let r = match code { + 1 => Self::SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT, + 2 => Self::SSH_DISCONNECT_PROTOCOL_ERROR, + 3 => Self::SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + 4 => Self::SSH_DISCONNECT_RESERVED, + 5 => Self::SSH_DISCONNECT_MAC_ERROR, + 6 => Self::SSH_DISCONNECT_COMPRESSION_ERROR, + 7 => Self::SSH_DISCONNECT_SERVICE_NOT_AVAILABLE, + 8 => Self::SSH_DISCONNECT_PROTOCOL_VERSION_NOT_SUPPORTED, + 9 => Self::SSH_DISCONNECT_HOST_KEY_NOT_VERIFIABLE, + 10 => Self::SSH_DISCONNECT_CONNECTION_LOST, + 11 => Self::SSH_DISCONNECT_BY_APPLICATION, + 12 => Self::SSH_DISCONNECT_TOO_MANY_CONNECTIONS, + 13 => Self::SSH_DISCONNECT_AUTH_CANCELLED_BY_USER, + 14 => Self::SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, + 15 => Self::SSH_DISCONNECT_ILLEGAL_USER_NAME, + _ => return None, + }; + Some(r) + } +} + /// SSH agent message numbers /// /// [draft-miller-ssh-agent](https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-14#section-5.1) diff --git a/stdasync/src/cmdline_client.rs b/stdasync/src/cmdline_client.rs index b2a2f2b8..f594b39a 100644 --- a/stdasync/src/cmdline_client.rs +++ b/stdasync/src/cmdline_client.rs @@ -325,6 +325,18 @@ impl CmdlineClient { EscapeBanner(b.banner()?) ) } + CliEvent::Disconnected(d) => { + let desc = EscapeBanner(d.desc()?); + match d.reason() { + Some(r) => { + println!("Disconnected by server ({r:?}): {desc}") + } + None => println!( + "Disconnected by server (reason {}): {desc}", + d.reason_code() + ), + } + } CliEvent::Defunct => { trace!("break defunct"); break Ok::<_, Error>(());