use std::{collections::HashMap, marker::PhantomData, net::SocketAddr, sync::Arc};
use russh::{
Channel, ChannelId, ChannelOpenFailure, Pty, Sig,
server::{Auth, ChannelOpenHandle, Handle, Msg, Session},
};
use tokio::{
sync::{mpsc, mpsc::UnboundedSender},
task::JoinSet,
};
use crate::{
Device,
ssh::{SshAccept, TailnetServer},
};
type Request = (ChannelId, ChannelEvent);
#[derive(Debug, Clone)]
pub struct ChannelContext {
pub accept: SshAccept,
pub ssh_user: String,
pub remote: SocketAddr,
pub src_node: Option<crate::NodeInfo>,
pub conn_id: String,
}
pub trait ChannelHandler: Sized {
type Error: Into<std::io::Error> + std::error::Error;
const RECORDS_SESSION: bool = false;
fn new(
handle: tokio::runtime::Handle,
channel_id: ChannelId,
session: Handle,
dev: Arc<Device>,
ctx: &ChannelContext,
) -> impl Future<Output = Result<Self, Self::Error>> + Send;
fn handle_event(
&mut self,
event: &ChannelEvent,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
}
pub struct ChannelServer<H> {
channel_state: HashMap<ChannelId, ChannelState>,
remote: SocketAddr,
dev: Arc<Device>,
accepted: Option<ChannelContext>,
conn_id: String,
_handler: PhantomSend<H>,
}
struct PhantomSend<H>(PhantomData<fn() -> H>);
const MAX_CHANNELS_PER_CONN: usize = 16;
fn at_channel_cap(open_channels: usize) -> bool {
open_channels >= MAX_CHANNELS_PER_CONN
}
const DEFAULT_UNSUPPORTED_REFUSAL: &str =
"policy requires a capability this SSH server cannot provide";
fn unsupported_action_refusal(accept: &SshAccept, handler_records: bool) -> Option<String> {
let unsupported =
!accept.hold_and_delegate.is_empty() || (!accept.recorders.is_empty() && !handler_records);
if !unsupported {
return None;
}
if accept.recording_refusal_message.is_empty() {
Some(DEFAULT_UNSUPPORTED_REFUSAL.to_string())
} else {
Some(accept.recording_refusal_message.clone())
}
}
#[derive(thiserror::Error, Debug, Copy, Clone, PartialEq, Eq)]
#[error("no such channel")]
struct NoChannel;
struct ChannelState {
channel: ChannelId,
tx: UnboundedSender<Request>,
_joinset: JoinSet<()>,
}
impl ChannelState {
fn send(&self, event: ChannelEvent) {
if self.tx.send((self.channel, event)).is_err() {
tracing::error!(channel = %self.channel, "failed to send event");
}
}
}
impl<H> ChannelServer<H> {
fn get_channel(
&mut self,
id: ChannelId,
) -> Result<&mut ChannelState, Box<dyn std::error::Error + Send + Sync + 'static>> {
self.channel_state.get_mut(&id).ok_or(Box::new(NoChannel))
}
}
impl<H> TailnetServer for ChannelServer<H> {
fn new_client(dev: Arc<Device>, addr: SocketAddr) -> Self {
Self {
channel_state: Default::default(),
dev,
remote: addr,
accepted: None,
conn_id: crate::ssh::new_conn_id(crate::ssh::now_unix_secs()),
_handler: PhantomSend(PhantomData),
}
}
}
#[derive(Debug, Clone)]
pub enum ChannelEvent {
Data(Vec<u8>),
Resize {
width: u16,
height: u16,
},
Signal(Sig),
Close,
Eof,
}
impl<H> russh::server::Handler for ChannelServer<H>
where
H: ChannelHandler + Send,
H::Error: Send,
{
type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
#[tracing::instrument(skip_all, fields(user = %user, remote = ?self.remote))]
async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
match self.dev.authorize_ssh(self.remote, user).await {
Ok(crate::ssh::SshDecision::Accept(accept)) => {
if let Some(msg) = unsupported_action_refusal(&accept, H::RECORDS_SESSION) {
tracing::warn!(
local_user = %accept.local_user,
recorders = ?accept.recorders,
message = %msg,
"ssh: session refused: policy requires a capability this server cannot provide"
);
return Ok(Auth::reject());
}
tracing::debug!(
local_user = %accept.local_user,
recorders = ?accept.recorders,
"ssh: policy accepted connection"
);
let src_node = self
.dev
.peer_by_tailnet_ip(self.remote.ip())
.await
.unwrap_or_else(|e| {
tracing::debug!(error = %e, "ssh: re-reading the connecting peer");
None
});
self.accepted = Some(ChannelContext {
accept,
ssh_user: user.to_string(),
remote: self.remote,
src_node,
conn_id: self.conn_id.clone(),
});
Ok(Auth::Accept)
}
Ok(crate::ssh::SshDecision::Deny(reason)) => {
tracing::warn!(?reason, "ssh: policy denied connection");
Ok(Auth::reject())
}
Err(e) => {
tracing::error!(error = %e, "ssh: authorization failed; rejecting");
Ok(Auth::reject())
}
}
}
async fn channel_open_session(
&mut self,
channel: Channel<Msg>,
reply: ChannelOpenHandle,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::debug!(channel = ?channel.id(), "new session");
let Some(ctx) = self.accepted.clone() else {
tracing::error!(
channel = ?channel.id(),
"ssh: channel open with no accepted identity; refusing"
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
if at_channel_cap(self.channel_state.len()) {
tracing::warn!(
channel = ?channel.id(),
cap = MAX_CHANNELS_PER_CONN,
"ssh: per-connection channel cap reached; refusing new channel"
);
reply.reject(ChannelOpenFailure::ResourceShortage).await;
return Ok(());
}
let (tx, mut rx) = mpsc::unbounded_channel::<Request>();
let mut joinset = JoinSet::new();
let (channel_id, session_handle) = (channel.id(), session.handle());
let dev = self.dev.clone();
joinset.spawn(async move {
let rt = tokio::runtime::Handle::current();
let mut handler = match H::new(rt, channel_id, session_handle.clone(), dev, &ctx).await
{
Ok(handler) => handler,
Err(e) => {
let e = e.into();
tracing::error!(error = %e, %channel_id, "spawning channel handler");
if session_handle.close(channel_id).await.is_err() {
tracing::error!("failed closing channel after handler init error");
};
return;
}
};
while let Some((_channel, evt)) = rx.recv().await {
let result = handler.handle_event(&evt).await;
if let Err(e) = result {
let e = e.into();
tracing::error!(error = %e, %channel_id, ?evt, "handling event");
if session_handle.close(channel_id).await.is_err() {
tracing::error!("failed closing channel after event handler error");
};
break;
}
}
tracing::debug!(?channel_id, "closed");
});
self.channel_state.insert(
channel.id(),
ChannelState {
channel: channel.id(),
tx,
_joinset: joinset,
},
);
reply.accept().await;
Ok(())
}
async fn channel_close(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::trace!(?channel, "session closed");
self.get_channel(channel)?.send(ChannelEvent::Close);
self.channel_state.remove(&channel);
session.channel_success(channel)?;
Ok(())
}
async fn signal(
&mut self,
channel: ChannelId,
signal: Sig,
session: &mut Session,
) -> Result<(), Self::Error> {
self.get_channel(channel)?
.send(ChannelEvent::Signal(signal));
session.channel_success(channel)?;
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
self.get_channel(channel)?
.send(ChannelEvent::Data(data.into()));
session.channel_success(channel)?;
Ok(())
}
async fn channel_eof(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
self.get_channel(channel)?.send(ChannelEvent::Eof);
session.channel_success(channel)?;
Ok(())
}
async fn window_change_request(
&mut self,
channel: ChannelId,
col_width: u32,
row_height: u32,
_: u32,
_: u32,
session: &mut Session,
) -> Result<(), Self::Error> {
self.get_channel(channel)?.send(ChannelEvent::Resize {
width: col_width as _,
height: row_height as _,
});
session.channel_success(channel)?;
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_: &str,
col_width: u32,
row_height: u32,
_: u32,
_: u32,
_: &[(Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
self.get_channel(channel)?.send(ChannelEvent::Resize {
width: col_width as _,
height: row_height as _,
});
session.channel_success(channel)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_UNSUPPORTED_REFUSAL, MAX_CHANNELS_PER_CONN, at_channel_cap,
unsupported_action_refusal,
};
use crate::ssh::SshAccept;
#[test]
fn channel_cap_boundary_is_inclusive() {
assert!(!at_channel_cap(MAX_CHANNELS_PER_CONN - 1));
assert!(!at_channel_cap(15));
assert!(at_channel_cap(MAX_CHANNELS_PER_CONN));
assert!(at_channel_cap(16));
assert!(at_channel_cap(17));
assert_eq!(MAX_CHANNELS_PER_CONN, 16);
}
fn accept(recorders: &[&str], hold_and_delegate: &str, refusal_message: &str) -> SshAccept {
SshAccept {
local_user: "root".to_string(),
accept_env: Vec::new(),
session_duration_nanos: None,
allow_agent_forwarding: false,
allow_local_port_forwarding: false,
allow_remote_port_forwarding: false,
recorders: recorders.iter().map(|r| r.parse().unwrap()).collect(),
on_recording_failure: None,
hold_and_delegate: hold_and_delegate.to_string(),
recording_refusal_message: refusal_message.to_string(),
}
}
#[test]
fn recording_demand_is_gated_on_handler_support() {
let a = accept(&["192.0.2.10:8080"], "", "recording required by policy");
assert_eq!(
unsupported_action_refusal(&a, true),
None,
"a recording-capable handler must be allowed to start and record the session"
);
assert_eq!(
unsupported_action_refusal(&a, false),
Some("recording required by policy".to_string()),
"a handler that cannot record must not run a session the policy says to record"
);
}
#[test]
fn hold_and_delegate_is_refused_for_every_handler() {
let a = accept(&[], "https://control.example/ssh/action/xyz", "");
for handler_records in [true, false] {
assert_eq!(
unsupported_action_refusal(&a, handler_records),
Some(DEFAULT_UNSUPPORTED_REFUSAL.to_string()),
"holdAndDelegate must be refused (handler_records={handler_records})"
);
}
}
#[test]
fn normal_accept_is_not_refused() {
assert_eq!(
unsupported_action_refusal(&accept(&[], "", ""), false),
None
);
assert_eq!(
unsupported_action_refusal(&accept(&[], "", "ignored"), false),
None
);
}
}