Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions async/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 18 additions & 8 deletions src/conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use self::{
cliauth::CliAuth,
event::Banner,
event::{Banner, Disconnected},
packets::{AuthMethod, UserauthRequest},
};

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -279,7 +275,7 @@ impl<CS: CliServ> Conn<CS> {
let event = self.channels.progress(s);
if !event.is_none() {
// TODO better Dispatched constructor
return Ok(Dispatched { event, disconnect: false });
return Ok(Dispatched { event });
}
}

Expand Down Expand Up @@ -479,9 +475,12 @@ impl<CS: CliServ> Conn<CS> {
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 {
Expand Down Expand Up @@ -553,6 +552,17 @@ impl<CS: CliServ> Conn<CS> {
};
Ok(disp)
}

pub(crate) fn fetch_disconnect<'p>(
&self,
payload: &'p [u8],
) -> Result<Disconnected<'p>> {
if let Packet::Disconnect(d) = self.packet(payload)? {
Ok(Disconnected(d))
} else {
Error::bug()
}
}
}

impl Conn<Client> {
Expand Down
46 changes: 42 additions & 4 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> },
Expand All @@ -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",
};
Expand Down Expand Up @@ -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> {
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<'_> {
Expand All @@ -188,10 +215,10 @@ pub(crate) enum CliEventId {
SessionOpened(ChanNum),
SessionExit,
Banner,
Disconnected,
#[expect(unused)]
Defunct,
// TODO:
// Disconnected
// OpenTCPForwarded (new session)
// TCPDirectOpened (response)
}
Expand Down Expand Up @@ -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(),
}
}
Expand All @@ -238,6 +268,7 @@ impl CliEventId {
| Self::SessionOpened(_)
| Self::SessionExit
| Self::Banner
| Self::Disconnected
| Self::Defunct => false,
Self::Hostkey
| Self::Username
Expand Down Expand Up @@ -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,

Expand All @@ -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",
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions src/packets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
42 changes: 42 additions & 0 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<event::Disconnected<'_>> {
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();
Expand Down
Loading
Loading